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:
@@ -32,6 +32,7 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
||||||
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
||||||
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
||||||
|
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
@@ -94,12 +95,16 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
||||||
- Modal de convite (incluindo os motivos de falha por convite)
|
- Modal de convite (incluindo os motivos de falha por convite)
|
||||||
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
||||||
|
- Configurações → Área de trabalho (início automático, atualizações, instância)
|
||||||
|
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
|
||||||
|
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
|
||||||
|
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
|
||||||
|
- Explorar servidores (cartões, entrar, pedir para entrar)
|
||||||
|
|
||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
Configurações restantes (conta, vídeo, atalhos, instância) · configurações do
|
Configurações → Vídeo · Configurações → Instância · configurações do servidor e
|
||||||
servidor e cargos · telas de erro · explorar servidores · DMs em grupo
|
cargos · telas de erro · DMs em grupo (`GroupDmSettings`, `AddDmMemberModal`)
|
||||||
(`GroupDmSettings`, `AddDmMemberModal`)
|
|
||||||
|
|
||||||
## Correções pendentes
|
## Correções pendentes
|
||||||
|
|
||||||
|
|||||||
@@ -814,7 +814,14 @@ function initAutoUpdater(): void {
|
|||||||
|
|
||||||
let updateConfirmed = false;
|
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', () => {
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
lastCheckAt = Date.now();
|
||||||
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
});
|
});
|
||||||
@@ -836,6 +843,7 @@ function initAutoUpdater(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
||||||
|
downloaded = true;
|
||||||
const version = info.version.slice(0, 32);
|
const version = info.version.slice(0, 32);
|
||||||
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
||||||
mainWindow?.webContents.send('update-downloaded', { version });
|
mainWindow?.webContents.send('update-downloaded', { version });
|
||||||
@@ -875,17 +883,28 @@ function initAutoUpdater(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial check with 10s delay (existing behavior)
|
// A release published while the app sits open used to go unnoticed until
|
||||||
setTimeout(() => {
|
// the next poll — the startup check had long since run, and four hours is
|
||||||
updateConfirmed = false;
|
// a long time to look at a stale version. Poll more often, and also check
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
// when the user comes back to the window, which is exactly when they would
|
||||||
}, 10_000);
|
// 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)
|
const maybeCheck = (): void => {
|
||||||
setInterval(() => {
|
if (downloaded) return; // already staged; nothing left to look for
|
||||||
|
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
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 {
|
} catch {
|
||||||
// Auto-updater unavailable — recovery surface still functions, just
|
// Auto-updater unavailable — recovery surface still functions, just
|
||||||
// without update affordances. autoUpdaterRef stays null; recovery action
|
// without update affordances. autoUpdaterRef stays null; recovery action
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { getSpaceGradient } from '../../utils/gradients';
|
|||||||
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
|
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
|
||||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||||
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
|
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
export function ExplorePage() {
|
export function ExplorePage() {
|
||||||
|
const t = useT();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="font-bold text-txt-primary">Explore</span>
|
<span className="font-bold text-txt-primary">{t('explore.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
|
<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">
|
<div className="relative flex-1 max-w-xs ml-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search spaces..."
|
placeholder={t('explore.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
className="input-search w-full"
|
className="input-search w-full"
|
||||||
@@ -107,7 +109,7 @@ export function ExplorePage() {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{!discoveryEnabled && (
|
{!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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -124,9 +126,7 @@ export function ExplorePage() {
|
|||||||
<div className="flex flex-col items-center justify-center h-64 opacity-80">
|
<div className="flex flex-col items-center justify-center h-64 opacity-80">
|
||||||
<Mascot state="lonely" className="w-32 h-32 mb-3" />
|
<Mascot state="lonely" className="w-32 h-32 mb-3" />
|
||||||
<p className="text-txt-tertiary text-sm">
|
<p className="text-txt-tertiary text-sm">
|
||||||
{searchQuery
|
{searchQuery ? t('explore.empty.search') : t('explore.empty.none')}
|
||||||
? 'No spaces match your search.'
|
|
||||||
: 'No discoverable spaces yet.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
<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>
|
</svg>
|
||||||
<span className="text-[13px] text-accent-mint">
|
<span className="text-[13px] text-accent-mint">
|
||||||
You're in all discoverable spaces
|
{t('explore.allJoined')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -173,7 +173,7 @@ export function ExplorePage() {
|
|||||||
<path d="M7 10l5 5 5-5z" />
|
<path d="M7 10l5 5 5-5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary group-hover:text-txt-secondary transition-colors">
|
<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>
|
||||||
<span className="text-xs text-txt-tertiary/60">
|
<span className="text-xs text-txt-tertiary/60">
|
||||||
{joinedSpaces.length}
|
{joinedSpaces.length}
|
||||||
@@ -207,6 +207,7 @@ function SpaceCard({
|
|||||||
space: TaggedExploreSpace;
|
space: TaggedExploreSpace;
|
||||||
onJoinSuccess: (spaceId: string) => void;
|
onJoinSuccess: (spaceId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const {
|
const {
|
||||||
isJoined,
|
isJoined,
|
||||||
isPublic,
|
isPublic,
|
||||||
@@ -297,7 +298,7 @@ function SpaceCard({
|
|||||||
? 'bg-accent-mint/20 text-accent-mint'
|
? 'bg-accent-mint/20 text-accent-mint'
|
||||||
: 'bg-accent-amber/20 text-accent-amber'
|
: 'bg-accent-amber/20 text-accent-amber'
|
||||||
}`}>
|
}`}>
|
||||||
{isPublic ? 'Public' : 'Request'}
|
{isPublic ? t('explore.badge.public') : t('explore.badge.request')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,7 +331,7 @@ function SpaceCard({
|
|||||||
{space.description}
|
{space.description}
|
||||||
</p>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
|
{space.memberCount} {t(space.memberCount === 1 ? 'explore.member' : 'explore.members')}
|
||||||
</span>
|
</span>
|
||||||
{originLabel && (
|
{originLabel && (
|
||||||
<span className="flex items-center gap-1 text-txt-tertiary/70">
|
<span className="flex items-center gap-1 text-txt-tertiary/70">
|
||||||
@@ -360,7 +361,7 @@ function SpaceCard({
|
|||||||
onClick={handleViewSpace}
|
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"
|
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>
|
</button>
|
||||||
) : isPublic ? (
|
) : isPublic ? (
|
||||||
<button
|
<button
|
||||||
@@ -371,10 +372,10 @@ function SpaceCard({
|
|||||||
{joining ? (
|
{joining ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
Joining...
|
{t('explore.joining')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'Join Space'
|
t('explore.join')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : isPending ? (
|
) : isPending ? (
|
||||||
@@ -382,14 +383,14 @@ function SpaceCard({
|
|||||||
disabled
|
disabled
|
||||||
className="w-full py-2 bg-interactive-muted text-txt-tertiary text-sm font-medium rounded cursor-default"
|
className="w-full py-2 bg-interactive-muted text-txt-tertiary text-sm font-medium rounded cursor-default"
|
||||||
>
|
>
|
||||||
Request Pending
|
{t('explore.requestPending')}
|
||||||
</button>
|
</button>
|
||||||
) : showRequestForm ? (
|
) : showRequestForm ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={requestMessage}
|
value={requestMessage}
|
||||||
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
|
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
|
||||||
placeholder="Why do you want to join? (optional)"
|
placeholder={t('explore.request.placeholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="input-standard w-full resize-none"
|
className="input-standard w-full resize-none"
|
||||||
/>
|
/>
|
||||||
@@ -399,13 +400,13 @@ function SpaceCard({
|
|||||||
disabled={joining}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={cancelRequestForm}
|
onClick={cancelRequestForm}
|
||||||
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('explore.request.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,7 +415,7 @@ function SpaceCard({
|
|||||||
onClick={openRequestForm}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
|
|||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||||
import type { FederationOpResult } from '../../../utils/federationOps';
|
import type { FederationOpResult } from '../../../utils/federationOps';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
/**
|
/**
|
||||||
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
|
* 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
|
* 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() {
|
export function AccountPanel() {
|
||||||
|
const t = useT();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ export function AccountPanel() {
|
|||||||
try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ }
|
try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ }
|
||||||
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
|
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
|
setReattachError(err instanceof Error ? err.message : t('settings.account.detached.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setReattaching(false);
|
setReattaching(false);
|
||||||
setReattachArmed(false);
|
setReattachArmed(false);
|
||||||
@@ -205,7 +207,7 @@ export function AccountPanel() {
|
|||||||
const { filename } = await waitForTransferAttachment(tid);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
setAvatarFilename(filename);
|
setAvatarFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to upload avatar');
|
setError(t('settings.account.avatar.uploadFailed'));
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -225,7 +227,7 @@ export function AccountPanel() {
|
|||||||
const { filename } = await waitForTransferAttachment(tid);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
setBannerFilename(filename);
|
setBannerFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to upload banner');
|
setError(t('settings.account.banner.uploadFailed'));
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,9 +275,9 @@ export function AccountPanel() {
|
|||||||
if (bannerFilename !== null) updates.banner = bannerFilename;
|
if (bannerFilename !== null) updates.banner = bannerFilename;
|
||||||
|
|
||||||
await updateProfile(updates as Parameters<typeof updateProfile>[0]);
|
await updateProfile(updates as Parameters<typeof updateProfile>[0]);
|
||||||
addToast('Profile updated', 'success', 2000);
|
addToast(t('settings.account.saved'), 'success', 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update profile');
|
setError(err instanceof Error ? err.message : t('settings.account.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -286,18 +288,18 @@ export function AccountPanel() {
|
|||||||
setPasswordResults(null);
|
setPasswordResults(null);
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
setPasswordError('New password must be at least 8 characters');
|
setPasswordError(t('settings.account.password.tooShort'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmNewPassword) {
|
if (newPassword !== confirmNewPassword) {
|
||||||
setPasswordError('Passwords do not match');
|
setPasswordError(t('settings.account.password.mismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPasswordLoading(true);
|
setPasswordLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await changePassword(currentPassword, newPassword);
|
const results = await changePassword(currentPassword, newPassword);
|
||||||
addToast('Password changed', 'success', 2000);
|
addToast(t('settings.account.password.changed'), 'success', 2000);
|
||||||
setCurrentPassword('');
|
setCurrentPassword('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmNewPassword('');
|
setConfirmNewPassword('');
|
||||||
@@ -310,7 +312,7 @@ export function AccountPanel() {
|
|||||||
setPasswordResults(null);
|
setPasswordResults(null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPasswordError(err instanceof Error ? err.message : 'Failed to change password');
|
setPasswordError(err instanceof Error ? err.message : t('settings.account.password.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordLoading(false);
|
setPasswordLoading(false);
|
||||||
}
|
}
|
||||||
@@ -335,16 +337,17 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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 && (
|
{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">
|
<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>{' '}
|
<span className="font-medium text-txt-primary">{t('settings.account.detached.lead')}</span>{' '}
|
||||||
{user.homeInstance} was reset or is no longer available, so this account now operates locally on
|
{t('settings.account.detached.body', { instance: user.homeInstance })}
|
||||||
this instance — your profile and password are managed here.
|
|
||||||
{homeConnection && (
|
{homeConnection && (
|
||||||
<>
|
<>
|
||||||
{' '}As <span className="font-medium text-txt-primary">{homeConnection.username}</span> on{' '}
|
{' '}{t('settings.account.detached.relink', {
|
||||||
{user.homeInstance}, you can re-link this account — profile and presence will sync from there again.
|
username: homeConnection.username,
|
||||||
|
instance: user.homeInstance,
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReattach}
|
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"
|
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
|
{reattaching
|
||||||
? 'Re-attaching…'
|
? t('settings.account.detached.reattaching')
|
||||||
: reattachArmed
|
: reattachArmed
|
||||||
? `Confirm re-attach as ${homeConnection.username}`
|
? t('settings.account.detached.confirm', { username: homeConnection.username })
|
||||||
: `Re-attach to ${user.homeInstance}`}
|
: t('settings.account.detached.reattach', { instance: user.homeInstance })}
|
||||||
</button>
|
</button>
|
||||||
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
|
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
|
||||||
</>
|
</>
|
||||||
@@ -365,7 +368,7 @@ export function AccountPanel() {
|
|||||||
{/* ── Profile Customization ── */}
|
{/* ── Profile Customization ── */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Profile Customization
|
{t('settings.account.section.profile')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Preview Card */}
|
{/* 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">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
|
||||||
{/* Avatar upload */}
|
{/* Avatar upload */}
|
||||||
<div>
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -450,7 +453,7 @@ export function AccountPanel() {
|
|||||||
disabled={uploadingAvatar}
|
disabled={uploadingAvatar}
|
||||||
className="text-xs text-accent-primary hover:underline text-left"
|
className="text-xs text-accent-primary hover:underline text-left"
|
||||||
>
|
>
|
||||||
Change Avatar
|
{t('settings.account.avatar.change')}
|
||||||
</button>
|
</button>
|
||||||
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && (
|
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && (
|
||||||
<button
|
<button
|
||||||
@@ -458,7 +461,7 @@ export function AccountPanel() {
|
|||||||
onClick={handleRemoveAvatar}
|
onClick={handleRemoveAvatar}
|
||||||
className="text-xs text-txt-danger hover:underline text-left"
|
className="text-xs text-txt-danger hover:underline text-left"
|
||||||
>
|
>
|
||||||
Remove
|
{t('settings.account.remove')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +477,7 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* Banner upload */}
|
{/* Banner upload */}
|
||||||
<div>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => bannerInputRef.current?.click()}
|
onClick={() => bannerInputRef.current?.click()}
|
||||||
@@ -514,7 +517,7 @@ export function AccountPanel() {
|
|||||||
disabled={uploadingBanner}
|
disabled={uploadingBanner}
|
||||||
className="text-xs text-accent-primary hover:underline"
|
className="text-xs text-accent-primary hover:underline"
|
||||||
>
|
>
|
||||||
Change Banner
|
{t('settings.account.banner.change')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -522,7 +525,7 @@ export function AccountPanel() {
|
|||||||
disabled={uploadingBanner}
|
disabled={uploadingBanner}
|
||||||
className="text-xs text-accent-primary hover:underline"
|
className="text-xs text-accent-primary hover:underline"
|
||||||
>
|
>
|
||||||
Choose GIF
|
{t('settings.account.banner.chooseGif')}
|
||||||
</button>
|
</button>
|
||||||
{showBannerGif && (
|
{showBannerGif && (
|
||||||
<>
|
<>
|
||||||
@@ -539,7 +542,7 @@ export function AccountPanel() {
|
|||||||
onClick={handleRemoveBanner}
|
onClick={handleRemoveBanner}
|
||||||
className="text-xs text-txt-danger hover:underline"
|
className="text-xs text-txt-danger hover:underline"
|
||||||
>
|
>
|
||||||
Remove
|
{t('settings.account.remove')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +557,7 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* Avatar Color */}
|
{/* Avatar Color */}
|
||||||
<div>
|
<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">
|
<div className="flex gap-2">
|
||||||
{AVATAR_COLORS.map((key) => {
|
{AVATAR_COLORS.map((key) => {
|
||||||
const entry = AVATAR_GRADIENT_MAP[key];
|
const entry = AVATAR_GRADIENT_MAP[key];
|
||||||
@@ -578,7 +581,7 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* Banner Color */}
|
{/* Banner Color */}
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-7 gap-1.5 mb-2">
|
||||||
{[0, 1, 2].map((row) =>
|
{[0, 1, 2].map((row) =>
|
||||||
BANNER_COLOR_PRESETS.map((family) => {
|
BANNER_COLOR_PRESETS.map((family) => {
|
||||||
@@ -627,7 +630,7 @@ export function AccountPanel() {
|
|||||||
onClick={() => { setAccentColor(null); setCustomHex(''); }}
|
onClick={() => { setAccentColor(null); setCustomHex(''); }}
|
||||||
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Clear
|
{t('settings.account.clear')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -635,7 +638,7 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
value={bio}
|
value={bio}
|
||||||
@@ -643,7 +646,7 @@ export function AccountPanel() {
|
|||||||
if (e.target.value.length <= 190) setBio(e.target.value);
|
if (e.target.value.length <= 190) setBio(e.target.value);
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Tell the world about yourself..."
|
placeholder={t('settings.account.aboutMe.placeholder')}
|
||||||
className="input-standard w-full resize-none"
|
className="input-standard w-full resize-none"
|
||||||
maxLength={190}
|
maxLength={190}
|
||||||
/>
|
/>
|
||||||
@@ -657,23 +660,23 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* ── Account ── */}
|
{/* ── Account ── */}
|
||||||
<div>
|
<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 className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value as UserStatus)}
|
onChange={(e) => setStatus(e.target.value as UserStatus)}
|
||||||
className="input-standard w-full appearance-none"
|
className="input-standard w-full appearance-none"
|
||||||
>
|
>
|
||||||
<option value="online">Online</option>
|
<option value="online">{t('settings.account.status.online')}</option>
|
||||||
<option value="idle">Idle</option>
|
<option value="idle">{t('settings.account.status.idle')}</option>
|
||||||
<option value="dnd">Do Not Disturb</option>
|
<option value="dnd">{t('settings.account.status.dnd')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
@@ -683,13 +686,13 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customStatus}
|
value={customStatus}
|
||||||
onChange={(e) => setCustomStatus(e.target.value)}
|
onChange={(e) => setCustomStatus(e.target.value)}
|
||||||
className="input-standard w-full"
|
className="input-standard w-full"
|
||||||
placeholder="What are you up to?"
|
placeholder={t('settings.account.customStatus.placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -697,18 +700,18 @@ export function AccountPanel() {
|
|||||||
|
|
||||||
{/* ── Password ── */}
|
{/* ── Password ── */}
|
||||||
<div>
|
<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">
|
<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" />
|
<input type="text" autoComplete="username" value={user.username} readOnly tabIndex={-1} className="sr-only" />
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showCurrentPassword ? 'text' : 'password'}
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
className="input-standard w-full pr-10"
|
className="input-standard w-full pr-10"
|
||||||
placeholder="Enter current password"
|
placeholder={t('settings.account.password.current.placeholder')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -730,14 +733,14 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showNewPassword ? 'text' : 'password'}
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="input-standard w-full pr-10"
|
className="input-standard w-full pr-10"
|
||||||
placeholder="Minimum 6 characters"
|
placeholder={t('settings.account.password.new.placeholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -759,13 +762,13 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmNewPassword}
|
value={confirmNewPassword}
|
||||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||||
className="input-standard w-full"
|
className="input-standard w-full"
|
||||||
placeholder="Confirm new password"
|
placeholder={t('settings.account.password.confirm.placeholder')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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]">
|
<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>
|
<span className="text-txt-secondary">{r.origin}</span>
|
||||||
{r.success ? (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -793,23 +796,23 @@ export function AccountPanel() {
|
|||||||
disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Danger Zone ── */}
|
{/* ── Danger Zone ── */}
|
||||||
<div>
|
<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">
|
<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">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(true)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -826,14 +829,14 @@ export function AccountPanel() {
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Reset
|
{t('settings.account.reset')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading || uploadingAvatar || uploadingBanner}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,7 +849,7 @@ export function AccountPanel() {
|
|||||||
onClose={() => setAvatarCropSrc(null)}
|
onClose={() => setAvatarCropSrc(null)}
|
||||||
imageSrc={avatarCropSrc ?? ''}
|
imageSrc={avatarCropSrc ?? ''}
|
||||||
onCropComplete={handleAvatarCropComplete}
|
onCropComplete={handleAvatarCropComplete}
|
||||||
title="Crop Avatar"
|
title={t('settings.account.crop.avatar')}
|
||||||
cropShape="round"
|
cropShape="round"
|
||||||
aspectRatio={1}
|
aspectRatio={1}
|
||||||
maxOutputDimension={256}
|
maxOutputDimension={256}
|
||||||
@@ -856,7 +859,7 @@ export function AccountPanel() {
|
|||||||
onClose={() => setBannerCropSrc(null)}
|
onClose={() => setBannerCropSrc(null)}
|
||||||
imageSrc={bannerCropSrc ?? ''}
|
imageSrc={bannerCropSrc ?? ''}
|
||||||
onCropComplete={handleBannerCropComplete}
|
onCropComplete={handleBannerCropComplete}
|
||||||
title="Crop Banner"
|
title={t('settings.account.crop.banner')}
|
||||||
cropShape="rect"
|
cropShape="rect"
|
||||||
aspectRatio={3}
|
aspectRatio={3}
|
||||||
maxOutputDimension={1280}
|
maxOutputDimension={1280}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../../stores/voiceStore';
|
|||||||
import { AudioManager } from '../../../audio/AudioManager';
|
import { AudioManager } from '../../../audio/AudioManager';
|
||||||
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
||||||
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature-detect per-element output routing support. iOS Safari has zero
|
* Feature-detect per-element output routing support. iOS Safari has zero
|
||||||
@@ -35,6 +36,7 @@ export function AudioOutputSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AudioOutputSectionInner() {
|
function AudioOutputSectionInner() {
|
||||||
|
const t = useT();
|
||||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||||
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
|
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
|
||||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||||
@@ -98,8 +100,8 @@ function AudioOutputSectionInner() {
|
|||||||
|
|
||||||
if (permState === 'unknown') {
|
if (permState === 'unknown') {
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Output Device">
|
<SectionShell title={t('settings.output.title')}>
|
||||||
<div className="text-sm text-txt-tertiary">Checking audio access…</div>
|
<div className="text-sm text-txt-tertiary">{t('settings.output.checking')}</div>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,9 +110,10 @@ function AudioOutputSectionInner() {
|
|||||||
// is not granted we can still let the user adjust output volume + test the
|
// is not granted we can still let the user adjust output volume + test the
|
||||||
// current default, but the picker is hidden.
|
// current default, but the picker is hidden.
|
||||||
const showPicker = permState === 'granted' && supportsSinkId;
|
const showPicker = permState === 'granted' && supportsSinkId;
|
||||||
|
const systemDefault = t('settings.output.systemDefault');
|
||||||
const selectedLabel = outputDeviceId === 'default'
|
const selectedLabel = outputDeviceId === 'default'
|
||||||
? 'System Default'
|
? systemDefault
|
||||||
: outputLabels.get(outputDeviceId) ?? 'System Default';
|
: outputLabels.get(outputDeviceId) ?? systemDefault;
|
||||||
|
|
||||||
const handleSelect = (id: string) => {
|
const handleSelect = (id: string) => {
|
||||||
setOutputDevice(id);
|
setOutputDevice(id);
|
||||||
@@ -123,7 +126,7 @@ function AudioOutputSectionInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Output Device">
|
<SectionShell title={t('settings.output.title')}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{showPicker ? (
|
{showPicker ? (
|
||||||
<div ref={dropdownRef}>
|
<div ref={dropdownRef}>
|
||||||
@@ -140,7 +143,7 @@ function AudioOutputSectionInner() {
|
|||||||
</button>
|
</button>
|
||||||
{listOpen && (
|
{listOpen && (
|
||||||
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1 max-h-64 overflow-y-auto">
|
<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) => (
|
{outputs.filter(d => d.deviceId !== 'default').map((d) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={d.deviceId}
|
key={d.deviceId}
|
||||||
@@ -154,25 +157,25 @@ function AudioOutputSectionInner() {
|
|||||||
</div>
|
</div>
|
||||||
) : permState === 'granted' && !supportsSinkId ? (
|
) : permState === 'granted' && !supportsSinkId ? (
|
||||||
<div className="text-xs text-txt-tertiary">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-txt-tertiary">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { requestPermission().catch(() => {}); }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<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 className="text-xs text-txt-tertiary tabular-nums">{outputVolume}%</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -195,7 +198,7 @@ function AudioOutputSectionInner() {
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
Play test sound
|
{t('settings.output.testSound')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
function AutoLaunchSettings() {
|
function AutoLaunchSettings() {
|
||||||
|
const t = useT();
|
||||||
const [openAtLogin, setOpenAtLogin] = useState(false);
|
const [openAtLogin, setOpenAtLogin] = useState(false);
|
||||||
const [startMinimized, setStartMinimized] = useState(true);
|
const [startMinimized, setStartMinimized] = useState(true);
|
||||||
const [loading, setLoading] = 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 items-center justify-between py-1">
|
||||||
<div className="flex-1 mr-4">
|
<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">
|
<div className="text-xs text-txt-tertiary mt-0.5">
|
||||||
Automatically launch Backspace when you log in
|
{t('settings.desktop.startAtBoot.description')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={openAtLogin} onChange={handleOpenAtLoginChange} disabled={busy} />
|
<Toggle enabled={openAtLogin} onChange={handleOpenAtLoginChange} disabled={busy} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div className="flex-1 mr-4">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={startMinimized} onChange={handleStartMinimizedChange} disabled={busy || !openAtLogin} />
|
<Toggle enabled={startMinimized} onChange={handleStartMinimizedChange} disabled={busy || !openAtLogin} />
|
||||||
@@ -70,6 +72,7 @@ function AutoLaunchSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UpdateSettings() {
|
function UpdateSettings() {
|
||||||
|
const t = useT();
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
@@ -88,10 +91,10 @@ function UpdateSettings() {
|
|||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div className="flex-1 mr-4">
|
<div className="flex-1 mr-4">
|
||||||
<div className="text-sm text-txt-primary">
|
<div className="text-sm text-txt-primary">
|
||||||
{version ? `Version ${version}` : 'Backspace Desktop'}
|
{version ? t('settings.desktop.version', { version }) : t('settings.desktop.appName')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-txt-tertiary mt-0.5">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -99,16 +102,17 @@ function UpdateSettings() {
|
|||||||
disabled={checking}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopPanel() {
|
export function DesktopPanel() {
|
||||||
|
const t = useT();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
|
||||||
<AutoLaunchSettings />
|
<AutoLaunchSettings />
|
||||||
@@ -119,13 +123,13 @@ export function DesktopPanel() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary font-medium">{window.location.origin}</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.backspace?.clearInstanceUrl()}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useKeybindStore, BINDABLE_ACTIONS, Keybind } from '../../../stores/keybindStore';
|
import { useKeybindStore, BINDABLE_ACTIONS, Keybind } from '../../../stores/keybindStore';
|
||||||
import { isElectron, isElectronMac } from '../../../platform/platform';
|
import { isElectron, isElectronMac } from '../../../platform/platform';
|
||||||
|
import { useT, type TranslationKey } from '../../../i18n';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -50,6 +51,7 @@ interface KeybindRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, onStartRecording, onDelete, rowRef }: KeybindRowProps) {
|
function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, onStartRecording, onDelete, rowRef }: KeybindRowProps) {
|
||||||
|
const t = useT();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rowRef}
|
ref={rowRef}
|
||||||
@@ -64,12 +66,12 @@ function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, o
|
|||||||
<div className="text-xs text-txt-tertiary mt-0.5">
|
<div className="text-xs text-txt-tertiary mt-0.5">
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<span className="text-accent-mint animate-pulse">
|
<span className="text-accent-mint animate-pulse">
|
||||||
{recordingDisplay || 'Press a key combo...'}
|
{recordingDisplay || t('settings.keybinds.pressCombo')}
|
||||||
</span>
|
</span>
|
||||||
) : keybind ? (
|
) : keybind ? (
|
||||||
keybind.displayLabel
|
keybind.displayLabel
|
||||||
) : (
|
) : (
|
||||||
'Not bound'
|
t('settings.keybinds.notBound')
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,20 +82,20 @@ function KeybindRow({ actionId, label, keybind, isRecording, recordingDisplay, o
|
|||||||
onClick={onStartRecording}
|
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"
|
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>
|
</button>
|
||||||
{keybind && (
|
{keybind && (
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isRecording && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +117,7 @@ interface ConflictInfo {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function KeybindsPanel() {
|
export function KeybindsPanel() {
|
||||||
|
const t = useT();
|
||||||
const { keybinds, setKeybind, removeKeybind, findConflict } = useKeybindStore();
|
const { keybinds, setKeybind, removeKeybind, findConflict } = useKeybindStore();
|
||||||
|
|
||||||
const [recordingActionId, setRecordingActionId] = useState<string | null>(null);
|
const [recordingActionId, setRecordingActionId] = useState<string | null>(null);
|
||||||
@@ -286,13 +289,13 @@ export function KeybindsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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 */}
|
{/* macOS Accessibility Warning */}
|
||||||
{isElectronMac() && accessibilityTrusted === false && (
|
{isElectronMac() && accessibilityTrusted === false && (
|
||||||
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
|
<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">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -308,9 +311,20 @@ export function KeybindsPanel() {
|
|||||||
{/* Linux hook error warning */}
|
{/* Linux hook error warning */}
|
||||||
{isElectron() && hookError && (
|
{isElectron() && hookError && (
|
||||||
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3.5">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -318,21 +332,21 @@ export function KeybindsPanel() {
|
|||||||
{/* Web limitation note */}
|
{/* Web limitation note */}
|
||||||
{!isElectron() && (
|
{!isElectron() && (
|
||||||
<div className="text-xs text-txt-tertiary px-1">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keybind rows */}
|
{/* Keybind rows */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Voice Shortcuts
|
{t('settings.keybinds.section.voice')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{BINDABLE_ACTIONS.map((action) => (
|
{BINDABLE_ACTIONS.map((action) => (
|
||||||
<KeybindRow
|
<KeybindRow
|
||||||
key={action.id}
|
key={action.id}
|
||||||
actionId={action.id}
|
actionId={action.id}
|
||||||
label={action.label}
|
label={t(action.labelKey as TranslationKey)}
|
||||||
keybind={getKeybind(action.id)}
|
keybind={getKeybind(action.id)}
|
||||||
isRecording={recordingActionId === action.id}
|
isRecording={recordingActionId === action.id}
|
||||||
recordingDisplay={recordingDisplay}
|
recordingDisplay={recordingDisplay}
|
||||||
@@ -351,24 +365,28 @@ export function KeybindsPanel() {
|
|||||||
{conflict && (
|
{conflict && (
|
||||||
<div className="rounded-lg bg-surface-elevated border border-white/[0.06] p-3.5">
|
<div className="rounded-lg bg-surface-elevated border border-white/[0.06] p-3.5">
|
||||||
<div className="text-sm text-txt-primary">
|
<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">
|
<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>
|
</span>
|
||||||
. Overwrite?
|
{t('settings.keybinds.conflict.suffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-2.5">
|
<div className="flex gap-2 mt-2.5">
|
||||||
<button
|
<button
|
||||||
onClick={confirmConflict}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={cancelConflict}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,6 +363,135 @@ export const en = {
|
|||||||
'invite.fail.invalid_target': 'Invalid target',
|
'invite.fail.invalid_target': 'Invalid target',
|
||||||
'invite.fail.network': "Couldn't reach your instance",
|
'invite.fail.network': "Couldn't reach your instance",
|
||||||
'invite.fail.generic': "Couldn't send (server error)",
|
'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;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof en;
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|||||||
@@ -362,4 +362,133 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'invite.fail.invalid_target': 'Destino inválido',
|
'invite.fail.invalid_target': 'Destino inválido',
|
||||||
'invite.fail.network': 'Não deu para falar com a sua instância',
|
'invite.fail.network': 'Não deu para falar com a sua instância',
|
||||||
'invite.fail.generic': 'Não deu para enviar (erro no servidor)',
|
'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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ export interface Keybind {
|
|||||||
displayLabel: string; // human-readable, captured at record time
|
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 = [
|
export const BINDABLE_ACTIONS = [
|
||||||
{ id: 'toggleMute', label: 'Toggle Mute', type: 'toggle' as const },
|
{ id: 'toggleMute', label: 'Toggle Mute', labelKey: 'settings.keybinds.action.toggleMute', type: 'toggle' as const },
|
||||||
{ id: 'toggleDeafen', label: 'Toggle Deafen', type: 'toggle' as const },
|
{ id: 'toggleDeafen', label: 'Toggle Deafen', labelKey: 'settings.keybinds.action.toggleDeafen', type: 'toggle' as const },
|
||||||
{ id: 'pushToTalk', label: 'Push to Talk', type: 'hold' as const },
|
{ id: 'pushToTalk', label: 'Push to Talk', labelKey: 'settings.keybinds.action.pushToTalk', type: 'hold' as const },
|
||||||
{ id: 'toggleCamera', label: 'Toggle Camera', type: 'toggle' as const },
|
{ id: 'toggleCamera', label: 'Toggle Camera', labelKey: 'settings.keybinds.action.toggleCamera', type: 'toggle' as const },
|
||||||
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', type: 'toggle' as const },
|
{ id: 'toggleScreenShare', label: 'Toggle Screen Share', labelKey: 'settings.keybinds.action.toggleScreenShare', type: 'toggle' as const },
|
||||||
{ id: 'disconnect', label: 'Disconnect', type: 'toggle' as const },
|
{ id: 'disconnect', label: 'Disconnect', labelKey: 'settings.keybinds.action.disconnect', type: 'toggle' as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Mouse buttons that must not be bound (would break OS interaction) */
|
/** Mouse buttons that must not be bound (would break OS interaction) */
|
||||||
|
|||||||
Reference in New Issue
Block a user