feat(i18n): translate five more systems, and unify the permission labels
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

- Space settings > Overview — identity, icon, banner, transfer, delete
  - Space settings > Members and Bans
  - Space settings > Roles, including the whole permission table
  - Category settings — rename, privacy, overrides, delete
  - Instance settings > General — name, discovery, GIF search

The permission labels were duplicated: the roles panel carried one English
list and the channel/category override editors carried another. Both now
read a single `perm.*` set, so renaming a permission lands in all three at
once instead of one and a half. This removes the `channel.perm.*` keys added
two commits ago rather than adding a third copy beside them.

Same pattern as before for anything chosen outside the render: the roles
panel's permission groups and the category's override definitions hold keys
and resolve them where they render. The category delete dialog emphasises
the category name, so its sentence is split around the placeholder rather
than interpolated.

Dictionary is at 723 keys, en and pt-BR at exact parity.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:49:25 -03:00
co-authored by Claude Opus 5
parent 4fd6ac890c
commit 94f5da4013
10 changed files with 511 additions and 202 deletions
@@ -7,6 +7,7 @@ import { parseFederatedUsername, isFederationGlobeApplicable } from '../../../ut
import { useCanonicalUserView } from '../../../utils/userViewLookup';
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
import type { MemberWithUser, Role } from '@backspace/shared';
import { useT, type TranslationKey } from '../../../i18n';
function MembersPanelRow({
member,
@@ -43,6 +44,7 @@ function MembersPanelRow({
onCancelRoleChange: (userId: string) => void;
onPendingAction: (action: { type: 'kick' | 'ban'; userId: string; displayName: string }) => void;
}) {
const t = useT();
const canonical = useCanonicalUserView(member.user);
const isOwner = member.userId === ownerId;
const displayName = canonical.displayName ?? canonical.username;
@@ -88,7 +90,7 @@ function MembersPanelRow({
</span>
))}
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
<span className="text-[10px] text-txt-tertiary">No roles</span>
<span className="text-[10px] text-txt-tertiary">{t('space.members.noRoles')}</span>
)}
</div>
</div>
@@ -100,7 +102,7 @@ function MembersPanelRow({
onClick={(e) => { e.stopPropagation(); onPendingAction({ type: 'ban', userId: member.userId, displayName }); }}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
Ban
{t('space.members.ban')}
</button>
)}
{canKick && member.userId !== currentUserId && !isOwner && (
@@ -108,7 +110,7 @@ function MembersPanelRow({
onClick={(e) => { e.stopPropagation(); onPendingAction({ type: 'kick', userId: member.userId, displayName }); }}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
Kick
{t('space.members.kick')}
</button>
)}
{expandable && (
@@ -150,13 +152,13 @@ function MembersPanelRow({
onClick={() => onSaveRoles(member.userId)}
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
>
Save
{t('space.members.save')}
</button>
<button
onClick={() => onCancelRoleChange(member.userId)}
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
{t('space.members.cancel')}
</button>
</div>
)}
@@ -171,6 +173,7 @@ interface MembersPanelProps {
}
export function MembersPanel({ spaceId }: MembersPanelProps) {
const t = useT();
const spaces = useSpaceStore((s) => s.spaces);
const members = useSpaceStore((s) => s.members);
const roles = useSpaceStore((s) => s.roles);
@@ -224,7 +227,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
setExpandedMemberId(null);
await loadSpaceDetail(spaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update roles');
setError(err instanceof Error ? err.message : t('space.members.rolesFailed'));
}
};
@@ -241,7 +244,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
await spaceApi.spaces.removeMember(spaceId, userId);
await loadSpaceDetail(spaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
setError(err instanceof Error ? err.message : t('space.members.kickFailed'));
}
};
@@ -250,7 +253,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
await spaceApi.spaces.ban(spaceId, userId);
await loadSpaceDetail(spaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to ban member');
setError(err instanceof Error ? err.message : t('space.members.banFailed'));
}
};
@@ -259,15 +262,15 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Members</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.members.title')}</h2>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
<p className="text-xs text-txt-tertiary">Manage members of this space. Click a member to edit their roles.</p>
<p className="text-xs text-txt-tertiary">{t('space.members.description')}</p>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Members ({members.length})
{t('space.members.count', { count: members.length })}
</div>
<div className="rounded-lg bg-white/[0.02] p-2">
<div className="space-y-0.5">
@@ -308,14 +311,17 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
}
setPendingAction(null);
}}
title={pendingAction?.type === 'ban' ? `Ban ${pendingAction.displayName}` : `Kick ${pendingAction?.displayName ?? ''}`}
description={
title={t(
pendingAction?.type === 'ban' ? 'space.members.ban.title' : 'space.members.kick.title',
{ name: pendingAction?.displayName ?? '' },
)}
description={t(
pendingAction?.type === 'ban'
? 'They will be permanently banned from this space until unbanned.'
: 'They can rejoin with an invite link.'
}
? 'space.members.ban.description'
: 'space.members.kick.description',
)}
variant={pendingAction?.type === 'ban' ? 'danger' : 'warning'}
confirmLabel={pendingAction?.type === 'ban' ? 'Ban' : 'Kick'}
confirmLabel={t(pendingAction?.type === 'ban' ? 'space.members.ban' : 'space.members.kick')}
/>
</div>
);