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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user