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
@@ -8,22 +8,25 @@ import { Toggle } from '../ui/Toggle';
import { PermissionsEditor } from '../ui/PermissionsEditor';
import type { PermissionDef } from '../ui/OverrideEntry';
type CategoryPermissionDef = Omit<PermissionDef, 'label'> & { labelKey: TranslationKey };
import { useT, translate, useLocaleStore, type TranslationKey } from '../../i18n';
// ─── Permission Definitions for Category Overrides ──────────────────────────────
const CATEGORY_PERMISSIONS: PermissionDef[] = [
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
{ key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES },
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES },
{ key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES },
{ key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY },
{ key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS },
{ key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT },
{ key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK },
{ key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM },
{ key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS },
{ key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS },
{ key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS },
{ key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS },
const CATEGORY_PERMISSIONS: CategoryPermissionDef[] = [
{ key: 'VIEW_CHANNEL', labelKey: 'perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL },
{ key: 'SEND_MESSAGES', labelKey: 'perm.sendMessages', bit: PermissionBits.SEND_MESSAGES },
{ key: 'MANAGE_MESSAGES', labelKey: 'perm.manageMessages', bit: PermissionBits.MANAGE_MESSAGES },
{ key: 'ATTACH_FILES', labelKey: 'perm.attachFiles', bit: PermissionBits.ATTACH_FILES },
{ key: 'READ_MESSAGE_HISTORY', labelKey: 'perm.readHistory', bit: PermissionBits.READ_MESSAGE_HISTORY },
{ key: 'ADD_REACTIONS', labelKey: 'perm.addReactions', bit: PermissionBits.ADD_REACTIONS },
{ key: 'CONNECT', labelKey: 'perm.connect', bit: PermissionBits.CONNECT },
{ key: 'SPEAK', labelKey: 'perm.speak', bit: PermissionBits.SPEAK },
{ key: 'STREAM', labelKey: 'perm.stream', bit: PermissionBits.STREAM },
{ key: 'MUTE_MEMBERS', labelKey: 'perm.muteMembers', bit: PermissionBits.MUTE_MEMBERS },
{ key: 'DEAFEN_MEMBERS', labelKey: 'perm.deafenMembers', bit: PermissionBits.DEAFEN_MEMBERS },
{ key: 'MOVE_MEMBERS', labelKey: 'perm.moveMembers', bit: PermissionBits.MOVE_MEMBERS },
{ key: 'DISCONNECT_MEMBERS', labelKey: 'perm.disconnectMembers', bit: PermissionBits.DISCONNECT_MEMBERS },
];
// ─── Overview Tab ───────────────────────────────────────────────────────────────
@@ -53,6 +56,7 @@ function OverviewTab({
onDeleteCategory: () => void;
onRename: (name: string) => void;
}) {
const t = useT();
const [editName, setEditName] = useState(categoryName);
const [isSavingName, setIsSavingName] = useState(false);
@@ -86,7 +90,7 @@ function OverviewTab({
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Category
{t('category.label')}
</label>
{canManageChannels ? (
<div className="flex items-center gap-2">
@@ -123,9 +127,9 @@ function OverviewTab({
<div className="pt-2 border-t border-border-soft">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-txt-primary">Private Category</div>
<div className="text-sm font-medium text-txt-primary">{t('category.private.title')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Only selected members and roles will be able to view channels in this category.
{t('category.private.description')}
</div>
</div>
<div className={`flex-shrink-0 ml-4 ${(isLoading || isFetching || !canManageRoles) ? 'opacity-50 pointer-events-none' : ''}`}>
@@ -140,19 +144,19 @@ function OverviewTab({
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<span>
This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it.
{t('category.private.note')}
</span>
</div>
)}
{canManageChannels && (
<div className="pt-4 border-t border-border-soft">
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">Danger Zone</label>
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">{t('category.danger')}</label>
<button
onClick={onDeleteCategory}
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors"
>
Delete Category
{t('category.delete')}
</button>
</div>
)}
@@ -163,6 +167,7 @@ function OverviewTab({
// ─── Main Modal ─────────────────────────────────────────────────────────────────
export function CategorySettingsModal() {
const t = useT();
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
@@ -220,7 +225,7 @@ export function CategorySettingsModal() {
}
})
.catch((err: Error) => {
setError(err.message || 'Failed to load category overrides');
setError(err.message || translate(useLocaleStore.getState().locale, 'category.private.loadFailed'));
})
.finally(() => {
setIsFetching(false);
@@ -263,7 +268,7 @@ export function CategorySettingsModal() {
// Re-fetch to keep in sync
fetchPrivateState();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update category privacy');
setError(err instanceof Error ? err.message : t('category.private.updateFailed'));
} finally {
setIsLoading(false);
}
@@ -274,7 +279,7 @@ export function CategorySettingsModal() {
try {
await useSpaceStore.getState().updateCategory(categoryId, { name });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename category');
setError(err instanceof Error ? err.message : t('category.renameFailed'));
}
};
@@ -285,7 +290,7 @@ export function CategorySettingsModal() {
await useSpaceStore.getState().deleteCategory(categoryId);
closeModal();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete category');
setError(err instanceof Error ? err.message : t('category.delete.failed'));
setIsDeleting(false);
}
};
@@ -299,17 +304,17 @@ export function CategorySettingsModal() {
return (
<>
<Modal isOpen={isOpen} onClose={closeModal} title="Category Settings" mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
<Modal isOpen={isOpen} onClose={closeModal} title={t('category.settings.title')} mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
{showTabs ? (
<div className="flex gap-4 h-[min(520px,70vh)]">
{/* Tabs */}
<div className="w-32 flex-shrink-0 self-start z-10">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
Overview
{t('category.tab.overview')}
</button>
<button onClick={() => setTab('permissions')} className={tabClass('permissions')}>
Permissions
{t('category.tab.permissions')}
</button>
</div>
</div>
@@ -336,7 +341,7 @@ export function CategorySettingsModal() {
entityId={categoryId}
spaceId={currentSpaceId}
instanceOrigin={space?._instanceOrigin}
permDefs={CATEGORY_PERMISSIONS}
permDefs={CATEGORY_PERMISSIONS.map(({ labelKey, ...rest }) => ({ ...rest, label: t(labelKey) }))}
getOverrides={() => {
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
return catApi.categories.getOverrides(categoryId);
@@ -374,11 +379,12 @@ export function CategorySettingsModal() {
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDeleteCategory}
title={`Delete "${category.name}"?`}
description={<>
This will permanently delete the <strong>{category.name}</strong> category. Channels inside it will be moved to the top level. This action cannot be undone.
</>}
confirmLabel="Delete Category"
title={t('category.delete.title', { name: category.name })}
description={(() => {
const [before, after] = t('category.delete.body').split('{name}');
return (<>{before}<strong>{category.name}</strong>{after}</>);
})()}
confirmLabel={t('category.delete')}
variant="danger"
loading={isDeleting}
/>