feat(i18n): translate five more systems, and check for updates more often
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

Update cadence: the desktop app checked once ten seconds after launch and
then every four hours. A release published while the app sits open went
unnoticed until the next poll — which is exactly what happened with 1.4.0.
It now polls every 30 minutes and also checks when the window regains focus,
throttled to one check per five minutes so alt-tabbing does not hammer the
feed. `lastCheckAt` is stamped from the `checking-for-update` event rather
than by the callers, so a manual check from the settings panel counts too.
The focus listener is on `app`, not on mainWindow, because the window is
destroyed and recreated when closed to tray.

Translations, five whole systems:
  - Settings > Desktop (autostart, updates, instance)
  - Settings > Voice: output device, which completes that tab
  - Settings > Keybinds, including the action names and conflict prompt
  - Settings > Account, the largest — profile, password, detached-instance
    notice, danger zone
  - Explore spaces

Two sentences wrap a literal that must not be translated (the `input` group
name, the `user@instance` example). Those are split around a placeholder
rather than interpolated as text, so a translation keeps control of the word
order on both sides of it. Keybind action labels gained a `labelKey`
alongside the English `label`, which stays for non-React callers.

Verified: web build (tsc + vite), desktop typecheck, 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 13:38:28 -03:00
co-authored by Claude Opus 5
parent a54d998ba9
commit 115c81cba3
10 changed files with 448 additions and 132 deletions
+27 -8
View File
@@ -814,7 +814,14 @@ function initAutoUpdater(): void {
let updateConfirmed = false;
// Cadence state. `lastCheckAt` is stamped from the event rather than from
// the callers, so a manual "check for updates" from the settings panel
// counts too and a focus arriving right after it doesn't check twice.
let lastCheckAt = 0;
let downloaded = false;
autoUpdater.on('checking-for-update', () => {
lastCheckAt = Date.now();
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
updateConfirmed = false;
});
@@ -836,6 +843,7 @@ function initAutoUpdater(): void {
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
downloaded = true;
const version = info.version.slice(0, 32);
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
mainWindow?.webContents.send('update-downloaded', { version });
@@ -875,17 +883,28 @@ function initAutoUpdater(): void {
}
});
// Initial check with 10s delay (existing behavior)
setTimeout(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// A release published while the app sits open used to go unnoticed until
// the next poll — the startup check had long since run, and four hours is
// a long time to look at a stale version. Poll more often, and also check
// when the user comes back to the window, which is exactly when they would
// notice an update anyway.
const POLL_INTERVAL_MS = 30 * 60 * 1000;
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
// Periodic check every 4 hours (existing behavior)
setInterval(() => {
const maybeCheck = (): void => {
if (downloaded) return; // already staged; nothing left to look for
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
};
setTimeout(maybeCheck, 10_000);
setInterval(maybeCheck, POLL_INTERVAL_MS);
// App-level rather than mainWindow-level: the window is destroyed and
// recreated when closed to tray and reopened, and a listener bound to the
// old instance would go quiet after the first close.
app.on('browser-window-focus', maybeCheck);
} catch {
// Auto-updater unavailable — recovery surface still functions, just
// without update affordances. autoUpdaterRef stays null; recovery action
@@ -8,8 +8,10 @@ import { getSpaceGradient } from '../../utils/gradients';
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
import { useT } from '../../i18n';
export function ExplorePage() {
const t = useT();
const navigate = useNavigate();
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
@@ -73,7 +75,7 @@ export function ExplorePage() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
<span className="font-bold text-txt-primary">Explore</span>
<span className="font-bold text-txt-primary">{t('explore.title')}</span>
</div>
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
@@ -81,7 +83,7 @@ export function ExplorePage() {
<div className="relative flex-1 max-w-xs ml-2">
<input
type="text"
placeholder="Search spaces..."
placeholder={t('explore.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="input-search w-full"
@@ -107,7 +109,7 @@ export function ExplorePage() {
<div className="flex-1 overflow-y-auto">
{!discoveryEnabled && (
<div className="mx-6 mt-4 p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Space discovery is disabled by the instance administrator.
{t('explore.disabled')}
</div>
)}
@@ -124,9 +126,7 @@ export function ExplorePage() {
<div className="flex flex-col items-center justify-center h-64 opacity-80">
<Mascot state="lonely" className="w-32 h-32 mb-3" />
<p className="text-txt-tertiary text-sm">
{searchQuery
? 'No spaces match your search.'
: 'No discoverable spaces yet.'}
{searchQuery ? t('explore.empty.search') : t('explore.empty.none')}
</p>
</div>
) : (
@@ -138,7 +138,7 @@ export function ExplorePage() {
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span className="text-[13px] text-accent-mint">
You're in all discoverable spaces
{t('explore.allJoined')}
</span>
</div>
)}
@@ -173,7 +173,7 @@ export function ExplorePage() {
<path d="M7 10l5 5 5-5z" />
</svg>
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary group-hover:text-txt-secondary transition-colors">
Joined
{t('explore.joined')}
</span>
<span className="text-xs text-txt-tertiary/60">
{joinedSpaces.length}
@@ -207,6 +207,7 @@ function SpaceCard({
space: TaggedExploreSpace;
onJoinSuccess: (spaceId: string) => void;
}) {
const t = useT();
const {
isJoined,
isPublic,
@@ -297,7 +298,7 @@ function SpaceCard({
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-accent-amber/20 text-accent-amber'
}`}>
{isPublic ? 'Public' : 'Request'}
{isPublic ? t('explore.badge.public') : t('explore.badge.request')}
</span>
</div>
@@ -330,7 +331,7 @@ function SpaceCard({
{space.description}
</p>
) : (
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">No description</p>
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">{t('explore.noDescription')}</p>
)}
<div className="flex items-center gap-3 text-[12px] text-txt-tertiary mb-3">
@@ -338,7 +339,7 @@ function SpaceCard({
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg>
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
{space.memberCount} {t(space.memberCount === 1 ? 'explore.member' : 'explore.members')}
</span>
{originLabel && (
<span className="flex items-center gap-1 text-txt-tertiary/70">
@@ -360,7 +361,7 @@ function SpaceCard({
onClick={handleViewSpace}
className="w-full py-2 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-sm font-medium rounded transition-colors"
>
View Space
{t('explore.view')}
</button>
) : isPublic ? (
<button
@@ -371,10 +372,10 @@ function SpaceCard({
{joining ? (
<span className="flex items-center justify-center gap-2">
<LoadingSpinner />
Joining...
{t('explore.joining')}
</span>
) : (
'Join Space'
t('explore.join')
)}
</button>
) : isPending ? (
@@ -382,14 +383,14 @@ function SpaceCard({
disabled
className="w-full py-2 bg-interactive-muted text-txt-tertiary text-sm font-medium rounded cursor-default"
>
Request Pending
{t('explore.requestPending')}
</button>
) : showRequestForm ? (
<div className="space-y-2">
<textarea
value={requestMessage}
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
placeholder="Why do you want to join? (optional)"
placeholder={t('explore.request.placeholder')}
rows={2}
className="input-standard w-full resize-none"
/>
@@ -399,13 +400,13 @@ function SpaceCard({
disabled={joining}
className="flex-1 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-[#13131a] text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{joining ? 'Sending...' : 'Send Request'}
{joining ? t('explore.request.sending') : t('explore.request.send')}
</button>
<button
onClick={cancelRequestForm}
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
{t('explore.request.cancel')}
</button>
</div>
</div>
@@ -414,7 +415,7 @@ function SpaceCard({
onClick={openRequestForm}
className="w-full py-2 bg-accent-amber/20 hover:bg-accent-amber/30 text-accent-amber text-sm font-medium rounded transition-colors"
>
Request to Join
{t('explore.requestToJoin')}
</button>
)}
</div>
@@ -14,6 +14,7 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
import { AVATAR_COLORS } from '@backspace/shared';
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
import type { FederationOpResult } from '../../../utils/federationOps';
import { useT } from '../../../i18n';
/**
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
* remote `https:` URL (GIF picker). Only the former owns memory that must be
@@ -25,6 +26,7 @@ function releasePreview(url: string | null): void {
}
export function AccountPanel() {
const t = useT();
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
@@ -129,7 +131,7 @@ export function AccountPanel() {
try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ }
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
} catch (err) {
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
setReattachError(err instanceof Error ? err.message : t('settings.account.detached.failed'));
} finally {
setReattaching(false);
setReattachArmed(false);
@@ -205,7 +207,7 @@ export function AccountPanel() {
const { filename } = await waitForTransferAttachment(tid);
setAvatarFilename(filename);
} catch {
setError('Failed to upload avatar');
setError(t('settings.account.avatar.uploadFailed'));
setAvatarPreview(null);
URL.revokeObjectURL(previewUrl);
} finally {
@@ -225,7 +227,7 @@ export function AccountPanel() {
const { filename } = await waitForTransferAttachment(tid);
setBannerFilename(filename);
} catch {
setError('Failed to upload banner');
setError(t('settings.account.banner.uploadFailed'));
setBannerPreview(null);
URL.revokeObjectURL(previewUrl);
} finally {
@@ -273,9 +275,9 @@ export function AccountPanel() {
if (bannerFilename !== null) updates.banner = bannerFilename;
await updateProfile(updates as Parameters<typeof updateProfile>[0]);
addToast('Profile updated', 'success', 2000);
addToast(t('settings.account.saved'), 'success', 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
setError(err instanceof Error ? err.message : t('settings.account.saveFailed'));
} finally {
setIsLoading(false);
}
@@ -286,18 +288,18 @@ export function AccountPanel() {
setPasswordResults(null);
if (newPassword.length < 8) {
setPasswordError('New password must be at least 8 characters');
setPasswordError(t('settings.account.password.tooShort'));
return;
}
if (newPassword !== confirmNewPassword) {
setPasswordError('Passwords do not match');
setPasswordError(t('settings.account.password.mismatch'));
return;
}
setPasswordLoading(true);
try {
const results = await changePassword(currentPassword, newPassword);
addToast('Password changed', 'success', 2000);
addToast(t('settings.account.password.changed'), 'success', 2000);
setCurrentPassword('');
setNewPassword('');
setConfirmNewPassword('');
@@ -310,7 +312,7 @@ export function AccountPanel() {
setPasswordResults(null);
}, 5000);
} catch (err) {
setPasswordError(err instanceof Error ? err.message : 'Failed to change password');
setPasswordError(err instanceof Error ? err.message : t('settings.account.password.failed'));
} finally {
setPasswordLoading(false);
}
@@ -335,16 +337,17 @@ export function AccountPanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">My Account</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('settings.account.title')}</h2>
{user?.federationHomeOrphaned && user?.homeInstance && (
<div className="rounded-lg bg-accent-amber/10 border border-accent-amber/25 px-3.5 py-3 text-xs text-txt-secondary leading-relaxed mb-4">
<span className="font-medium text-txt-primary">This account is detached from its home instance.</span>{' '}
{user.homeInstance} was reset or is no longer available, so this account now operates locally on
this instance — your profile and password are managed here.
<span className="font-medium text-txt-primary">{t('settings.account.detached.lead')}</span>{' '}
{t('settings.account.detached.body', { instance: user.homeInstance })}
{homeConnection && (
<>
{' '}As <span className="font-medium text-txt-primary">{homeConnection.username}</span> on{' '}
{user.homeInstance}, you can re-link this account — profile and presence will sync from there again.
{' '}{t('settings.account.detached.relink', {
username: homeConnection.username,
instance: user.homeInstance,
})}
<button
type="button"
onClick={handleReattach}
@@ -352,10 +355,10 @@ export function AccountPanel() {
className="mt-2 block rounded-md bg-accent-amber/20 hover:bg-accent-amber/30 disabled:opacity-50 text-txt-primary px-3 py-1.5 text-xs font-medium transition-colors"
>
{reattaching
? 'Re-attaching…'
? t('settings.account.detached.reattaching')
: reattachArmed
? `Confirm re-attach as ${homeConnection.username}`
: `Re-attach to ${user.homeInstance}`}
? t('settings.account.detached.confirm', { username: homeConnection.username })
: t('settings.account.detached.reattach', { instance: user.homeInstance })}
</button>
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
</>
@@ -365,7 +368,7 @@ export function AccountPanel() {
{/* ── Profile Customization ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Profile Customization
{t('settings.account.section.profile')}
</div>
{/* Live Preview Card */}
@@ -407,7 +410,7 @@ export function AccountPanel() {
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
{/* Avatar upload */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Avatar</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.avatar')}</label>
<div className="flex items-center gap-3">
<button
type="button"
@@ -450,7 +453,7 @@ export function AccountPanel() {
disabled={uploadingAvatar}
className="text-xs text-accent-primary hover:underline text-left"
>
Change Avatar
{t('settings.account.avatar.change')}
</button>
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && (
<button
@@ -458,7 +461,7 @@ export function AccountPanel() {
onClick={handleRemoveAvatar}
className="text-xs text-txt-danger hover:underline text-left"
>
Remove
{t('settings.account.remove')}
</button>
)}
</div>
@@ -474,7 +477,7 @@ export function AccountPanel() {
{/* Banner upload */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Banner</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.banner')}</label>
<button
type="button"
onClick={() => bannerInputRef.current?.click()}
@@ -514,7 +517,7 @@ export function AccountPanel() {
disabled={uploadingBanner}
className="text-xs text-accent-primary hover:underline"
>
Change Banner
{t('settings.account.banner.change')}
</button>
<button
type="button"
@@ -522,7 +525,7 @@ export function AccountPanel() {
disabled={uploadingBanner}
className="text-xs text-accent-primary hover:underline"
>
Choose GIF
{t('settings.account.banner.chooseGif')}
</button>
{showBannerGif && (
<>
@@ -539,7 +542,7 @@ export function AccountPanel() {
onClick={handleRemoveBanner}
className="text-xs text-txt-danger hover:underline"
>
Remove
{t('settings.account.remove')}
</button>
)}
</div>
@@ -554,7 +557,7 @@ export function AccountPanel() {
{/* Avatar Color */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Avatar Color</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.avatarColor')}</label>
<div className="flex gap-2">
{AVATAR_COLORS.map((key) => {
const entry = AVATAR_GRADIENT_MAP[key];
@@ -578,7 +581,7 @@ export function AccountPanel() {
{/* Banner Color */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Banner Color</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.bannerColor')}</label>
<div className="grid grid-cols-7 gap-1.5 mb-2">
{[0, 1, 2].map((row) =>
BANNER_COLOR_PRESETS.map((family) => {
@@ -627,7 +630,7 @@ export function AccountPanel() {
onClick={() => { setAccentColor(null); setCustomHex(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Clear
{t('settings.account.clear')}
</button>
)}
</div>
@@ -635,7 +638,7 @@ export function AccountPanel() {
{/* Bio */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">About Me</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.aboutMe')}</label>
<div className="relative">
<textarea
value={bio}
@@ -643,7 +646,7 @@ export function AccountPanel() {
if (e.target.value.length <= 190) setBio(e.target.value);
}}
rows={3}
placeholder="Tell the world about yourself..."
placeholder={t('settings.account.aboutMe.placeholder')}
className="input-standard w-full resize-none"
maxLength={190}
/>
@@ -657,23 +660,23 @@ export function AccountPanel() {
{/* ── Account ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Account</div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('settings.account.section.account')}</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Status</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.status')}</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as UserStatus)}
className="input-standard w-full appearance-none"
>
<option value="online">Online</option>
<option value="idle">Idle</option>
<option value="dnd">Do Not Disturb</option>
<option value="online">{t('settings.account.status.online')}</option>
<option value="idle">{t('settings.account.status.idle')}</option>
<option value="dnd">{t('settings.account.status.dnd')}</option>
</select>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Display Name</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.displayName')}</label>
<input
type="text"
value={displayName}
@@ -683,13 +686,13 @@ export function AccountPanel() {
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Custom Status</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.customStatus')}</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
className="input-standard w-full"
placeholder="What are you up to?"
placeholder={t('settings.account.customStatus.placeholder')}
/>
</div>
</div>
@@ -697,18 +700,18 @@ export function AccountPanel() {
{/* ── Password ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Password</div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('settings.account.section.password')}</div>
<form onSubmit={(e) => { e.preventDefault(); handleChangePassword(); }} className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
<input type="text" autoComplete="username" value={user.username} readOnly tabIndex={-1} className="sr-only" />
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Current Password</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.password.current')}</label>
<div className="relative">
<input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="input-standard w-full pr-10"
placeholder="Enter current password"
placeholder={t('settings.account.password.current.placeholder')}
autoComplete="current-password"
/>
<button
@@ -730,14 +733,14 @@ export function AccountPanel() {
</div>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">New Password</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.password.new')}</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="input-standard w-full pr-10"
placeholder="Minimum 6 characters"
placeholder={t('settings.account.password.new.placeholder')}
autoComplete="new-password"
/>
<button
@@ -759,13 +762,13 @@ export function AccountPanel() {
</div>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Confirm New Password</label>
<label className="block text-xs text-txt-secondary mb-1.5">{t('settings.account.password.confirm')}</label>
<input
type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
className="input-standard w-full"
placeholder="Confirm new password"
placeholder={t('settings.account.password.confirm.placeholder')}
autoComplete="new-password"
/>
</div>
@@ -779,9 +782,9 @@ export function AccountPanel() {
<div key={r.origin} className="flex items-center justify-between text-xs px-2 py-1 rounded bg-white/[0.02]">
<span className="text-txt-secondary">{r.origin}</span>
{r.success ? (
<span className="text-status-online">Synced</span>
<span className="text-status-online">{t('settings.account.sync.synced')}</span>
) : (
<span className="text-txt-danger" title={r.error}>Failed — will sync on reconnect</span>
<span className="text-txt-danger" title={r.error}>{t('settings.account.sync.failed')}</span>
)}
</div>
))}
@@ -793,23 +796,23 @@ export function AccountPanel() {
disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{passwordLoading ? 'Changing...' : 'Change Password'}
{passwordLoading ? t('settings.account.password.changing') : t('settings.account.password.change')}
</button>
</form>
</div>
{/* ── Danger Zone ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Danger Zone</div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('settings.account.section.danger')}</div>
<div className="rounded-lg bg-accent-rose/5 border border-accent-rose/20 p-3.5">
<p className="text-sm text-txt-secondary mb-3">
Once you delete your account, there is no going back. Your messages will remain but be attributed to "Deleted User".
{t('settings.account.delete.warning')}
</p>
<button
onClick={() => setShowDeleteModal(true)}
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors"
>
Delete Account
{t('settings.account.delete')}
</button>
</div>
</div>
@@ -826,14 +829,14 @@ export function AccountPanel() {
onClick={handleReset}
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Reset
{t('settings.account.reset')}
</button>
<button
onClick={handleSave}
disabled={isLoading || uploadingAvatar || uploadingBanner}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save'}
{isLoading ? t('settings.account.saving') : t('settings.account.save')}
</button>
</div>
</div>
@@ -846,7 +849,7 @@ export function AccountPanel() {
onClose={() => setAvatarCropSrc(null)}
imageSrc={avatarCropSrc ?? ''}
onCropComplete={handleAvatarCropComplete}
title="Crop Avatar"
title={t('settings.account.crop.avatar')}
cropShape="round"
aspectRatio={1}
maxOutputDimension={256}
@@ -856,7 +859,7 @@ export function AccountPanel() {
onClose={() => setBannerCropSrc(null)}
imageSrc={bannerCropSrc ?? ''}
onCropComplete={handleBannerCropComplete}
title="Crop Banner"
title={t('settings.account.crop.banner')}
cropShape="rect"
aspectRatio={3}
maxOutputDimension={1280}
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../../stores/voiceStore';
import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices';
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
import { useT } from '../../../i18n';
/**
* Feature-detect per-element output routing support. iOS Safari has zero
@@ -35,6 +36,7 @@ export function AudioOutputSection() {
}
function AudioOutputSectionInner() {
const t = useT();
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
const outputVolume = useVoiceStore((s) => s.outputVolume);
@@ -98,8 +100,8 @@ function AudioOutputSectionInner() {
if (permState === 'unknown') {
return (
<SectionShell title="Output Device">
<div className="text-sm text-txt-tertiary">Checking audio access…</div>
<SectionShell title={t('settings.output.title')}>
<div className="text-sm text-txt-tertiary">{t('settings.output.checking')}</div>
</SectionShell>
);
}
@@ -108,9 +110,10 @@ function AudioOutputSectionInner() {
// is not granted we can still let the user adjust output volume + test the
// current default, but the picker is hidden.
const showPicker = permState === 'granted' && supportsSinkId;
const systemDefault = t('settings.output.systemDefault');
const selectedLabel = outputDeviceId === 'default'
? 'System Default'
: outputLabels.get(outputDeviceId) ?? 'System Default';
? systemDefault
: outputLabels.get(outputDeviceId) ?? systemDefault;
const handleSelect = (id: string) => {
setOutputDevice(id);
@@ -123,7 +126,7 @@ function AudioOutputSectionInner() {
};
return (
<SectionShell title="Output Device">
<SectionShell title={t('settings.output.title')}>
<div className="space-y-3">
{showPicker ? (
<div ref={dropdownRef}>
@@ -140,7 +143,7 @@ function AudioOutputSectionInner() {
</button>
{listOpen && (
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1 max-h-64 overflow-y-auto">
<DropdownItem label="System Default" active={outputDeviceId === 'default'} onClick={() => handleSelect('default')} />
<DropdownItem label={systemDefault} active={outputDeviceId === 'default'} onClick={() => handleSelect('default')} />
{outputs.filter(d => d.deviceId !== 'default').map((d) => (
<DropdownItem
key={d.deviceId}
@@ -154,25 +157,25 @@ function AudioOutputSectionInner() {
</div>
) : permState === 'granted' && !supportsSinkId ? (
<div className="text-xs text-txt-tertiary">
This browser doesn't support choosing an output device. Audio plays to the system default.
{t('settings.output.unsupported')}
</div>
) : (
<div className="space-y-2">
<div className="text-xs text-txt-tertiary">
Grant microphone permission to list output devices (browsers gate output names behind microphone access).
{t('settings.output.needPermission')}
</div>
<button
onClick={() => { requestPermission().catch(() => {}); }}
className="text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white font-medium transition-colors"
>
Enable audio access
{t('settings.output.enableAccess')}
</button>
</div>
)}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[13px] font-medium text-txt-primary">Output Volume</div>
<div className="text-[13px] font-medium text-txt-primary">{t('settings.output.volume')}</div>
<div className="text-xs text-txt-tertiary tabular-nums">{outputVolume}%</div>
</div>
<input
@@ -195,7 +198,7 @@ function AudioOutputSectionInner() {
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
</svg>
Play test sound
{t('settings.output.testSound')}
</button>
</div>
</SectionShell>
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import { Toggle } from '../../ui/Toggle';
import { useT } from '../../../i18n';
function AutoLaunchSettings() {
const t = useT();
const [openAtLogin, setOpenAtLogin] = useState(false);
const [startMinimized, setStartMinimized] = useState(true);
const [loading, setLoading] = useState(true);
@@ -49,18 +51,18 @@ function AutoLaunchSettings() {
<>
<div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">Start at boot</div>
<div className="text-sm text-txt-primary">{t('settings.desktop.startAtBoot')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Automatically launch Backspace when you log in
{t('settings.desktop.startAtBoot.description')}
</div>
</div>
<Toggle enabled={openAtLogin} onChange={handleOpenAtLoginChange} disabled={busy} />
</div>
<div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4">
<div className={`text-sm ${openAtLogin ? 'text-txt-primary' : 'text-txt-tertiary'}`}>Start minimized</div>
<div className={`text-sm ${openAtLogin ? 'text-txt-primary' : 'text-txt-tertiary'}`}>{t('settings.desktop.startMinimized')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Start hidden in the system tray instead of showing the window
{t('settings.desktop.startMinimized.description')}
</div>
</div>
<Toggle enabled={startMinimized} onChange={handleStartMinimizedChange} disabled={busy || !openAtLogin} />
@@ -70,6 +72,7 @@ function AutoLaunchSettings() {
}
function UpdateSettings() {
const t = useT();
const [version, setVersion] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
@@ -88,10 +91,10 @@ function UpdateSettings() {
<div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">
{version ? `Version ${version}` : 'Backspace Desktop'}
{version ? t('settings.desktop.version', { version }) : t('settings.desktop.appName')}
</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Check for new versions of the desktop app
{t('settings.desktop.update.description')}
</div>
</div>
<button
@@ -99,16 +102,17 @@ function UpdateSettings() {
disabled={checking}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors disabled:opacity-50"
>
{checking ? 'Checking...' : 'Check for Updates'}
{checking ? t('settings.desktop.update.checking') : t('settings.desktop.update.check')}
</button>
</div>
);
}
export function DesktopPanel() {
const t = useT();
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Desktop</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('settings.desktop.title')}</h2>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
<AutoLaunchSettings />
@@ -119,13 +123,13 @@ export function DesktopPanel() {
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-txt-primary font-medium">{window.location.origin}</div>
<div className="text-xs text-txt-tertiary mt-0.5">Currently connected instance</div>
<div className="text-xs text-txt-tertiary mt-0.5">{t('settings.desktop.instance.current')}</div>
</div>
<button
onClick={() => window.backspace?.clearInstanceUrl()}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded-lg transition-colors"
>
Change Instance
{t('settings.desktop.instance.change')}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useKeybindStore, BINDABLE_ACTIONS, Keybind } from '../../../stores/keybindStore';
import { isElectron, isElectronMac } from '../../../platform/platform';
import { useT, type TranslationKey } from '../../../i18n';
// ---------------------------------------------------------------------------
// Constants
@@ -50,6 +51,7 @@ interface KeybindRowProps {
}
function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, onStartRecording, onDelete, rowRef }: KeybindRowProps) {
const t = useT();
return (
<div
ref={rowRef}
@@ -64,12 +66,12 @@ function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, o
<div className="text-xs text-txt-tertiary mt-0.5">
{isRecording ? (
<span className="text-accent-mint animate-pulse">
{recordingDisplay || 'Press a key combo...'}
{recordingDisplay || t('settings.keybinds.pressCombo')}
</span>
) : keybind ? (
keybind.displayLabel
) : (
'Not bound'
t('settings.keybinds.notBound')
)}
</div>
</div>
@@ -80,20 +82,20 @@ function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, o
onClick={onStartRecording}
className="text-xs px-2.5 py-1 rounded text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] transition-colors"
>
{keybind ? 'Edit' : 'Record'}
{keybind ? t('settings.keybinds.edit') : t('settings.keybinds.record')}
</button>
{keybind && (
<button
onClick={onDelete}
className="text-xs px-2.5 py-1 rounded text-txt-tertiary hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
>
Delete
{t('settings.keybinds.delete')}
</button>
)}
</>
)}
{isRecording && (
<span className="text-[10px] text-txt-tertiary">ESC to cancel</span>
<span className="text-[10px] text-txt-tertiary">{t('settings.keybinds.escToCancel')}</span>
)}
</div>
</div>
@@ -115,6 +117,7 @@ interface ConflictInfo {
// ---------------------------------------------------------------------------
export function KeybindsPanel() {
const t = useT();
const { keybinds, setKeybind, removeKeybind, findConflict } = useKeybindStore();
const [recordingActionId, setRecordingActionId] = useState<string | null>(null);
@@ -286,13 +289,13 @@ export function KeybindsPanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Keybinds</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('settings.keybinds.title')}</h2>
{/* macOS Accessibility Warning */}
{isElectronMac() && accessibilityTrusted === false && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
<div className="text-sm text-amber-200 font-medium">Accessibility Permission Required</div>
<div className="text-sm text-amber-200 font-medium">{t('settings.keybinds.accessibility.title')}</div>
<div className="text-xs text-amber-200/70 mt-1">
Backspace needs Accessibility permission for global shortcuts to work outside the app.
{t('settings.keybinds.accessibility.description')}
</div>
<button
onClick={() => {
@@ -300,7 +303,7 @@ export function KeybindsPanel() {
}}
className="mt-2 text-xs px-3 py-1.5 rounded bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 transition-colors"
>
Grant Permission
{t('settings.keybinds.accessibility.grant')}
</button>
</div>
)}
@@ -308,9 +311,20 @@ export function KeybindsPanel() {
{/* Linux hook error warning */}
{isElectron() && hookError && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
<div className="text-sm text-amber-200 font-medium">Global Shortcuts Unavailable</div>
<div className="text-sm text-amber-200 font-medium">{t('settings.keybinds.hookError.title')}</div>
<div className="text-xs text-amber-200/70 mt-1">
Failed to start input listener. On Linux, your user may need to be in the <code className="bg-black/20 px-1 rounded">input</code> group.
{/* The `input` group name is literal — split around it so the
sentence can be reordered freely by a translation. */}
{(() => {
const [before, after] = t('settings.keybinds.hookError.description').split('{group}');
return (
<>
{before}
<code className="bg-black/20 px-1 rounded">input</code>
{after}
</>
);
})()}
</div>
</div>
)}
@@ -318,21 +332,21 @@ export function KeybindsPanel() {
{/* Web limitation note */}
{!isElectron() && (
<div className="text-xs text-txt-tertiary px-1">
Shortcuts work while this tab is focused. For global shortcuts that work in other apps, use the desktop app.
{t('settings.keybinds.webNote')}
</div>
)}
{/* Keybind rows */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Shortcuts
{t('settings.keybinds.section.voice')}
</div>
<div className="space-y-1.5">
{BINDABLE_ACTIONS.map((action) => (
<KeybindRow
key={action.id}
actionId={action.id}
label={action.label}
label={t(action.labelKey as TranslationKey)}
keybind={getKeybind(action.id)}
isRecording={recordingActionId === action.id}
recordingDisplay={recordingDisplay}
@@ -351,24 +365,28 @@ export function KeybindsPanel() {
{conflict && (
<div className="rounded-lg bg-surface-elevated border border-white/[0.06] p-3.5">
<div className="text-sm text-txt-primary">
<span className="font-medium">{conflict.pendingKeybind.displayLabel}</span> is already bound to{' '}
<span className="font-medium">{conflict.pendingKeybind.displayLabel}</span>{' '}
{t('settings.keybinds.conflict.boundTo')}{' '}
<span className="font-medium">
{BINDABLE_ACTIONS.find((a) => a.id === conflict.existingKeybind.actionId)?.label}
{(() => {
const action = BINDABLE_ACTIONS.find((a) => a.id === conflict.existingKeybind.actionId);
return action ? t(action.labelKey as TranslationKey) : '';
})()}
</span>
. Overwrite?
{t('settings.keybinds.conflict.suffix')}
</div>
<div className="flex gap-2 mt-2.5">
<button
onClick={confirmConflict}
className="text-xs px-3 py-1.5 rounded bg-accent-mint/20 text-accent-mint hover:bg-accent-mint/30 transition-colors"
>
Overwrite
{t('settings.keybinds.conflict.overwrite')}
</button>
<button
onClick={cancelConflict}
className="text-xs px-3 py-1.5 rounded bg-white/[0.06] text-txt-secondary hover:bg-white/[0.1] transition-colors"
>
Cancel
{t('settings.keybinds.conflict.cancel')}
</button>
</div>
</div>
+129
View File
@@ -363,6 +363,135 @@ export const en = {
'invite.fail.invalid_target': 'Invalid target',
'invite.fail.network': "Couldn't reach your instance",
'invite.fail.generic': "Couldn't send (server error)",
// Settings — desktop
'settings.desktop.title': 'Desktop',
'settings.desktop.startAtBoot': 'Start at boot',
'settings.desktop.startAtBoot.description': 'Automatically launch Backspace when you log in',
'settings.desktop.startMinimized': 'Start minimized',
'settings.desktop.startMinimized.description': 'Start hidden in the system tray instead of showing the window',
'settings.desktop.version': 'Version {version}',
'settings.desktop.appName': 'Backspace Desktop',
'settings.desktop.update.description': 'Check for new versions of the desktop app',
'settings.desktop.update.check': 'Check for Updates',
'settings.desktop.update.checking': 'Checking...',
'settings.desktop.instance.current': 'Currently connected instance',
'settings.desktop.instance.change': 'Change Instance',
// Settings — voice: output
'settings.output.title': 'Output Device',
'settings.output.checking': 'Checking audio access…',
'settings.output.systemDefault': 'System Default',
'settings.output.unsupported': "This browser doesn't support choosing an output device. Audio plays to the system default.",
'settings.output.needPermission': 'Grant microphone permission to list output devices (browsers gate output names behind microphone access).',
'settings.output.enableAccess': 'Enable audio access',
'settings.output.volume': 'Output Volume',
'settings.output.testSound': 'Play test sound',
// Settings — keybinds
'settings.keybinds.title': 'Keybinds',
'settings.keybinds.section.voice': 'Voice Shortcuts',
'settings.keybinds.pressCombo': 'Press a key combo...',
'settings.keybinds.notBound': 'Not bound',
'settings.keybinds.edit': 'Edit',
'settings.keybinds.record': 'Record',
'settings.keybinds.delete': 'Delete',
'settings.keybinds.escToCancel': 'ESC to cancel',
'settings.keybinds.accessibility.title': 'Accessibility Permission Required',
'settings.keybinds.accessibility.description': 'Backspace needs Accessibility permission for global shortcuts to work outside the app.',
'settings.keybinds.accessibility.grant': 'Grant Permission',
'settings.keybinds.hookError.title': 'Global Shortcuts Unavailable',
'settings.keybinds.hookError.description': 'Failed to start input listener. On Linux, your user may need to be in the {group} group.',
'settings.keybinds.webNote': 'Shortcuts work while this tab is focused. For global shortcuts that work in other apps, use the desktop app.',
'settings.keybinds.conflict.suffix': '. Overwrite?',
'settings.keybinds.conflict.boundTo': 'is already bound to',
'settings.keybinds.conflict.overwrite': 'Overwrite',
'settings.keybinds.conflict.cancel': 'Cancel',
'settings.keybinds.action.toggleMute': 'Toggle Mute',
'settings.keybinds.action.toggleDeafen': 'Toggle Deafen',
'settings.keybinds.action.pushToTalk': 'Push to Talk',
'settings.keybinds.action.toggleCamera': 'Toggle Camera',
'settings.keybinds.action.toggleScreenShare': 'Toggle Screen Share',
'settings.keybinds.action.disconnect': 'Disconnect',
// Settings — account
'settings.account.title': 'My Account',
'settings.account.detached.lead': 'This account is detached from its home instance.',
'settings.account.detached.body': '{instance} was reset or is no longer available, so this account now operates locally on this instance — your profile and password are managed here.',
'settings.account.detached.relink': 'As {username} on {instance}, you can re-link this account — profile and presence will sync from there again.',
'settings.account.detached.reattaching': 'Re-attaching…',
'settings.account.detached.confirm': 'Confirm re-attach as {username}',
'settings.account.detached.reattach': 'Re-attach to {instance}',
'settings.account.detached.failed': 'Re-attach failed',
'settings.account.section.profile': 'Profile Customization',
'settings.account.section.account': 'Account',
'settings.account.section.password': 'Password',
'settings.account.section.danger': 'Danger Zone',
'settings.account.avatar': 'Avatar',
'settings.account.avatar.change': 'Change Avatar',
'settings.account.banner': 'Banner',
'settings.account.banner.change': 'Change Banner',
'settings.account.banner.chooseGif': 'Choose GIF',
'settings.account.remove': 'Remove',
'settings.account.clear': 'Clear',
'settings.account.avatarColor': 'Avatar Color',
'settings.account.bannerColor': 'Banner Color',
'settings.account.aboutMe': 'About Me',
'settings.account.aboutMe.placeholder': 'Tell the world about yourself...',
'settings.account.status': 'Status',
'settings.account.status.online': 'Online',
'settings.account.status.idle': 'Idle',
'settings.account.status.dnd': 'Do Not Disturb',
'settings.account.displayName': 'Display Name',
'settings.account.customStatus': 'Custom Status',
'settings.account.customStatus.placeholder': 'What are you up to?',
'settings.account.password.current': 'Current Password',
'settings.account.password.current.placeholder': 'Enter current password',
'settings.account.password.new': 'New Password',
'settings.account.password.new.placeholder': 'Minimum 6 characters',
'settings.account.password.confirm': 'Confirm New Password',
'settings.account.password.confirm.placeholder': 'Confirm new password',
'settings.account.password.change': 'Change Password',
'settings.account.password.changing': 'Changing...',
'settings.account.password.changed': 'Password changed',
'settings.account.password.tooShort': 'New password must be at least 8 characters',
'settings.account.password.mismatch': 'Passwords do not match',
'settings.account.password.failed': 'Failed to change password',
'settings.account.sync.synced': 'Synced',
'settings.account.sync.failed': 'Failed — will sync on reconnect',
'settings.account.delete.warning': 'Once you delete your account, there is no going back. Your messages will remain but be attributed to "Deleted User".',
'settings.account.delete': 'Delete Account',
'settings.account.reset': 'Reset',
'settings.account.save': 'Save',
'settings.account.saving': 'Saving...',
'settings.account.saved': 'Profile updated',
'settings.account.saveFailed': 'Failed to update profile',
'settings.account.avatar.uploadFailed': 'Failed to upload avatar',
'settings.account.banner.uploadFailed': 'Failed to upload banner',
'settings.account.crop.avatar': 'Crop Avatar',
'settings.account.crop.banner': 'Crop Banner',
// Explore spaces
'explore.title': 'Explore',
'explore.searchPlaceholder': 'Search spaces...',
'explore.disabled': 'Space discovery is disabled by the instance administrator.',
'explore.empty.search': 'No spaces match your search.',
'explore.empty.none': 'No discoverable spaces yet.',
'explore.allJoined': "You're in all discoverable spaces",
'explore.joined': 'Joined',
'explore.badge.public': 'Public',
'explore.badge.request': 'Request',
'explore.noDescription': 'No description',
'explore.member': 'member',
'explore.members': 'members',
'explore.view': 'View Space',
'explore.join': 'Join Space',
'explore.joining': 'Joining...',
'explore.requestPending': 'Request Pending',
'explore.requestToJoin': 'Request to Join',
'explore.request.placeholder': 'Why do you want to join? (optional)',
'explore.request.send': 'Send Request',
'explore.request.sending': 'Sending...',
'explore.request.cancel': 'Cancel',
} as const;
export type TranslationKey = keyof typeof en;
+129
View File
@@ -362,4 +362,133 @@ export const ptBR: Partial<Dictionary> = {
'invite.fail.invalid_target': 'Destino inválido',
'invite.fail.network': 'Não deu para falar com a sua instância',
'invite.fail.generic': 'Não deu para enviar (erro no servidor)',
// Configurações — área de trabalho
'settings.desktop.title': 'Área de trabalho',
'settings.desktop.startAtBoot': 'Abrir junto com o sistema',
'settings.desktop.startAtBoot.description': 'Inicia o Backspace sozinho quando você faz login',
'settings.desktop.startMinimized': 'Abrir minimizado',
'settings.desktop.startMinimized.description': 'Começa escondido na bandeja em vez de abrir a janela',
'settings.desktop.version': 'Versão {version}',
'settings.desktop.appName': 'Backspace Desktop',
'settings.desktop.update.description': 'Procurar versões novas do aplicativo',
'settings.desktop.update.check': 'Procurar atualizações',
'settings.desktop.update.checking': 'Procurando...',
'settings.desktop.instance.current': 'Instância conectada agora',
'settings.desktop.instance.change': 'Trocar de instância',
// Configurações — voz: saída
'settings.output.title': 'Dispositivo de saída',
'settings.output.checking': 'Verificando o acesso ao áudio…',
'settings.output.systemDefault': 'Padrão do sistema',
'settings.output.unsupported': 'Este navegador não deixa escolher o dispositivo de saída. O áudio toca no padrão do sistema.',
'settings.output.needPermission': 'Dê permissão de microfone para listar os dispositivos de saída (os navegadores escondem os nomes de saída atrás do acesso ao microfone).',
'settings.output.enableAccess': 'Liberar acesso ao áudio',
'settings.output.volume': 'Volume de saída',
'settings.output.testSound': 'Tocar som de teste',
// Configurações — atalhos
'settings.keybinds.title': 'Atalhos',
'settings.keybinds.section.voice': 'Atalhos de voz',
'settings.keybinds.pressCombo': 'Aperte a combinação...',
'settings.keybinds.notBound': 'Sem atalho',
'settings.keybinds.edit': 'Editar',
'settings.keybinds.record': 'Gravar',
'settings.keybinds.delete': 'Excluir',
'settings.keybinds.escToCancel': 'ESC para cancelar',
'settings.keybinds.accessibility.title': 'Precisa da permissão de Acessibilidade',
'settings.keybinds.accessibility.description': 'O Backspace precisa da permissão de Acessibilidade para os atalhos funcionarem fora do aplicativo.',
'settings.keybinds.accessibility.grant': 'Conceder permissão',
'settings.keybinds.hookError.title': 'Atalhos globais indisponíveis',
'settings.keybinds.hookError.description': 'Não foi possível iniciar o leitor de entrada. No Linux, talvez o seu usuário precise estar no grupo {group}.',
'settings.keybinds.webNote': 'Os atalhos funcionam enquanto esta aba estiver em foco. Para atalhos que funcionam em outros programas, use o aplicativo.',
'settings.keybinds.conflict.suffix': '. Substituir?',
'settings.keybinds.conflict.boundTo': 'já está ligado a',
'settings.keybinds.conflict.overwrite': 'Substituir',
'settings.keybinds.conflict.cancel': 'Cancelar',
'settings.keybinds.action.toggleMute': 'Ligar/desligar microfone',
'settings.keybinds.action.toggleDeafen': 'Ligar/desligar som',
'settings.keybinds.action.pushToTalk': 'Apertar para falar',
'settings.keybinds.action.toggleCamera': 'Ligar/desligar câmera',
'settings.keybinds.action.toggleScreenShare': 'Ligar/desligar transmissão',
'settings.keybinds.action.disconnect': 'Desconectar',
// Configurações — conta
'settings.account.title': 'Minha conta',
'settings.account.detached.lead': 'Esta conta está desligada da instância de origem.',
'settings.account.detached.body': 'A {instance} foi reiniciada ou não existe mais, então esta conta agora funciona localmente nesta instância — o perfil e a senha são gerenciados aqui.',
'settings.account.detached.relink': 'Como {username} em {instance}, dá para religar esta conta — o perfil e a presença voltam a sincronizar de lá.',
'settings.account.detached.reattaching': 'Religando…',
'settings.account.detached.confirm': 'Confirmar religação como {username}',
'settings.account.detached.reattach': 'Religar a {instance}',
'settings.account.detached.failed': 'Não foi possível religar',
'settings.account.section.profile': 'Personalização do perfil',
'settings.account.section.account': 'Conta',
'settings.account.section.password': 'Senha',
'settings.account.section.danger': 'Zona de perigo',
'settings.account.avatar': 'Foto de perfil',
'settings.account.avatar.change': 'Trocar foto',
'settings.account.banner': 'Banner',
'settings.account.banner.change': 'Trocar banner',
'settings.account.banner.chooseGif': 'Escolher GIF',
'settings.account.remove': 'Remover',
'settings.account.clear': 'Limpar',
'settings.account.avatarColor': 'Cor da foto',
'settings.account.bannerColor': 'Cor do banner',
'settings.account.aboutMe': 'Sobre mim',
'settings.account.aboutMe.placeholder': 'Conte alguma coisa sobre você...',
'settings.account.status': 'Status',
'settings.account.status.online': 'Online',
'settings.account.status.idle': 'Ausente',
'settings.account.status.dnd': 'Não perturbe',
'settings.account.displayName': 'Nome de exibição',
'settings.account.customStatus': 'Status personalizado',
'settings.account.customStatus.placeholder': 'O que você está fazendo?',
'settings.account.password.current': 'Senha atual',
'settings.account.password.current.placeholder': 'Digite a senha atual',
'settings.account.password.new': 'Senha nova',
'settings.account.password.new.placeholder': 'Mínimo de 6 caracteres',
'settings.account.password.confirm': 'Confirmar a senha nova',
'settings.account.password.confirm.placeholder': 'Confirme a senha nova',
'settings.account.password.change': 'Trocar senha',
'settings.account.password.changing': 'Trocando...',
'settings.account.password.changed': 'Senha trocada',
'settings.account.password.tooShort': 'A senha nova precisa ter pelo menos 8 caracteres',
'settings.account.password.mismatch': 'As senhas não batem',
'settings.account.password.failed': 'Não foi possível trocar a senha',
'settings.account.sync.synced': 'Sincronizado',
'settings.account.sync.failed': 'Falhou — sincroniza quando reconectar',
'settings.account.delete.warning': 'Depois de excluir a conta não tem volta. Suas mensagens continuam lá, mas passam a aparecer como "Usuário excluído".',
'settings.account.delete': 'Excluir conta',
'settings.account.reset': 'Desfazer',
'settings.account.save': 'Salvar',
'settings.account.saving': 'Salvando...',
'settings.account.saved': 'Perfil atualizado',
'settings.account.saveFailed': 'Não foi possível atualizar o perfil',
'settings.account.avatar.uploadFailed': 'Não foi possível enviar a foto',
'settings.account.banner.uploadFailed': 'Não foi possível enviar o banner',
'settings.account.crop.avatar': 'Recortar a foto',
'settings.account.crop.banner': 'Recortar o banner',
// Explorar servidores
'explore.title': 'Explorar',
'explore.searchPlaceholder': 'Buscar servidores...',
'explore.disabled': 'A descoberta de servidores está desativada pelo administrador da instância.',
'explore.empty.search': 'Nenhum servidor encontrado.',
'explore.empty.none': 'Nenhum servidor para descobrir ainda.',
'explore.allJoined': 'Você já está em todos os servidores visíveis',
'explore.joined': 'Você participa',
'explore.badge.public': 'Público',
'explore.badge.request': 'Por pedido',
'explore.noDescription': 'Sem descrição',
'explore.member': 'membro',
'explore.members': 'membros',
'explore.view': 'Ver servidor',
'explore.join': 'Entrar no servidor',
'explore.joining': 'Entrando...',
'explore.requestPending': 'Pedido pendente',
'explore.requestToJoin': 'Pedir para entrar',
'explore.request.placeholder': 'Por que você quer entrar? (opcional)',
'explore.request.send': 'Enviar pedido',
'explore.request.sending': 'Enviando...',
'explore.request.cancel': 'Cancelar',
};
+11 -6
View File
@@ -8,13 +8,18 @@ export interface Keybind {
displayLabel: string; // human-readable, captured at record time
}
/**
* `label` is the English source string; `labelKey` is what the settings panel
* renders, so the list re-labels when the language changes. Both are kept —
* the label stays useful anywhere a plain string is needed outside React.
*/
export const BINDABLE_ACTIONS = [
{ id: 'toggleMute', label: 'Toggle Mute', type: 'toggle' as const },
{ id: 'toggleDeafen', label: 'Toggle Deafen', type: 'toggle' as const },
{ id: 'pushToTalk', label: 'Push to Talk', type: 'hold' as const },
{ id: 'toggleCamera', label: 'Toggle Camera', type: 'toggle' as const },
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', type: 'toggle' as const },
{ id: 'disconnect', label: 'Disconnect', type: 'toggle' as const },
{ id: 'toggleMute', label: 'Toggle Mute', labelKey: 'settings.keybinds.action.toggleMute', type: 'toggle' as const },
{ id: 'toggleDeafen', label: 'Toggle Deafen', labelKey: 'settings.keybinds.action.toggleDeafen', type: 'toggle' as const },
{ id: 'pushToTalk', label: 'Push to Talk', labelKey: 'settings.keybinds.action.pushToTalk', type: 'hold' as const },
{ id: 'toggleCamera', label: 'Toggle Camera', labelKey: 'settings.keybinds.action.toggleCamera', type: 'toggle' as const },
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', labelKey: 'settings.keybinds.action.toggleScreenShare', type: 'toggle' as const },
{ id: 'disconnect', label: 'Disconnect', labelKey: 'settings.keybinds.action.disconnect', type: 'toggle' as const },
] as const;
/** Mouse buttons that must not be bound (would break OS interaction) */