From 94f5da4013a9cc8e22ca225d149b3e05acf7f0e1 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Wed, 2 Sep 2026 20:49:25 -0300 Subject: [PATCH] feat(i18n): translate five more systems, and unify the permission labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/roadmap-resenha.md | 16 +- .../modals/CategorySettingsModal.tsx | 72 ++++---- .../modals/ChannelSettingsModal.tsx | 28 +-- .../instanceSettingsPanels/GeneralPanel.tsx | 36 ++-- .../modals/spaceSettingsPanels/BansPanel.tsx | 20 ++- .../spaceSettingsPanels/MembersPanel.tsx | 40 +++-- .../spaceSettingsPanels/OverviewPanel.tsx | 66 +++---- .../modals/spaceSettingsPanels/RolesPanel.tsx | 101 ++++++----- packages/web/src/i18n/locales/en.ts | 167 ++++++++++++++++-- packages/web/src/i18n/locales/pt-BR.ts | 167 ++++++++++++++++-- 10 files changed, 511 insertions(+), 202 deletions(-) diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index 0db21249..916e0421 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -105,13 +105,21 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho. - DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair) - Entrar num servidor (descoberta, código de convite, conectar e login federado) - Excluir conta, transferir propriedade e a tela de erro geral +- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo) +- Configurações do servidor → Membros e banimentos +- Configurações do servidor → Cargos (incluindo a tabela de permissões) +- Configurações da categoria (renomear, privacidade, exceções, excluir) +- Configurações da instância → Geral (nome, descoberta, busca de GIFs) + +Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel +de cargos e pelos editores de exceção de canal e de categoria — antes havia +duas cópias da mesma lista. ### Fila sugerida de tradução -Configurações → Instância (painéis: geral, registro, federação, transmissão, -armazenamento, usuários) · configurações do servidor e cargos -(`SpaceSettings` e `spaceSettingsPanels/`) · telas móveis (`Mobile*.tsx`) · -`CategorySettingsModal` · `ConnectedInstances` +Configurações → Instância: registro, federação, transmissão, armazenamento e +usuários · chrome do `SpaceSettings` e do `UserSettings` (abas) · +`ConnectedInstances` · `UserProfileModal` · telas móveis (`Mobile*.tsx`) ## Correções pendentes diff --git a/packages/web/src/components/modals/CategorySettingsModal.tsx b/packages/web/src/components/modals/CategorySettingsModal.tsx index 8589a565..ecaf3d4c 100644 --- a/packages/web/src/components/modals/CategorySettingsModal.tsx +++ b/packages/web/src/components/modals/CategorySettingsModal.tsx @@ -8,22 +8,25 @@ import { Toggle } from '../ui/Toggle'; import { PermissionsEditor } from '../ui/PermissionsEditor'; import type { PermissionDef } from '../ui/OverrideEntry'; +type CategoryPermissionDef = Omit & { 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({
{canManageChannels ? (
@@ -123,9 +127,9 @@ function OverviewTab({
-
Private Category
+
{t('category.private.title')}
- Only selected members and roles will be able to view channels in this category. + {t('category.private.description')}
@@ -140,19 +144,19 @@ function OverviewTab({ - This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it. + {t('category.private.note')}
)} {canManageChannels && (
- +
)} @@ -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 ( <> - + {showTabs ? (
{/* Tabs */}
@@ -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 {category.name} 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}{category.name}{after}); + })()} + confirmLabel={t('category.delete')} variant="danger" loading={isDeleting} /> diff --git a/packages/web/src/components/modals/ChannelSettingsModal.tsx b/packages/web/src/components/modals/ChannelSettingsModal.tsx index 1d17772e..9661df62 100644 --- a/packages/web/src/components/modals/ChannelSettingsModal.tsx +++ b/packages/web/src/components/modals/ChannelSettingsModal.tsx @@ -18,23 +18,23 @@ import { type ChannelPermissionDef = Omit & { labelKey: TranslationKey }; const TEXT_CHANNEL_PERMISSIONS: ChannelPermissionDef[] = [ - { key: 'VIEW_CHANNEL', labelKey: 'channel.perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL }, - { key: 'SEND_MESSAGES', labelKey: 'channel.perm.sendMessages', bit: PermissionBits.SEND_MESSAGES }, - { key: 'MANAGE_MESSAGES', labelKey: 'channel.perm.manageMessages', bit: PermissionBits.MANAGE_MESSAGES }, - { key: 'ATTACH_FILES', labelKey: 'channel.perm.attachFiles', bit: PermissionBits.ATTACH_FILES }, - { key: 'READ_MESSAGE_HISTORY', labelKey: 'channel.perm.readHistory', bit: PermissionBits.READ_MESSAGE_HISTORY }, - { key: 'ADD_REACTIONS', labelKey: 'channel.perm.addReactions', bit: PermissionBits.ADD_REACTIONS }, + { 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 }, ]; const VOICE_CHANNEL_PERMISSIONS: ChannelPermissionDef[] = [ - { key: 'VIEW_CHANNEL', labelKey: 'channel.perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL }, - { key: 'CONNECT', labelKey: 'channel.perm.connect', bit: PermissionBits.CONNECT }, - { key: 'SPEAK', labelKey: 'channel.perm.speak', bit: PermissionBits.SPEAK }, - { key: 'STREAM', labelKey: 'channel.perm.stream', bit: PermissionBits.STREAM }, - { key: 'MUTE_MEMBERS', labelKey: 'channel.perm.muteMembers', bit: PermissionBits.MUTE_MEMBERS }, - { key: 'DEAFEN_MEMBERS', labelKey: 'channel.perm.deafenMembers', bit: PermissionBits.DEAFEN_MEMBERS }, - { key: 'MOVE_MEMBERS', labelKey: 'channel.perm.moveMembers', bit: PermissionBits.MOVE_MEMBERS }, - { key: 'DISCONNECT_MEMBERS', labelKey: 'channel.perm.disconnectMembers', bit: PermissionBits.DISCONNECT_MEMBERS }, + { key: 'VIEW_CHANNEL', labelKey: 'perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL }, + { 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 }, ]; /** Channel icon — the hash for a text channel, a padlock when it is private. */ diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index c8661997..54890fea 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -3,8 +3,10 @@ import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; import type { InstanceAdminSettings } from '@backspace/shared'; +import { useT, type TranslationKey } from '../../../i18n'; export function GeneralPanel() { + const t = useT(); const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); @@ -24,7 +26,7 @@ export function GeneralPanel() { } }, [instanceSettings]); - if (!draft) return
Loading settings...
; + if (!draft) return
{t('instance.general.loading')}
; const baseChanges = instanceSettings && draft ? draft.instanceName !== instanceSettings.instanceName || @@ -46,9 +48,9 @@ export function GeneralPanel() { await updateInstanceSettings(payload); setGifKeyDirty(false); setGifKeyDraft(''); - addToast('Settings saved', 'success', 2000); + addToast(t('instance.general.saved'), 'success', 2000); } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save'); + setSaveError(err instanceof Error ? err.message : t('instance.general.saveFailed')); } finally { setSaving(false); } @@ -63,15 +65,15 @@ export function GeneralPanel() { return (
e.preventDefault()}> -

General

+

{t('instance.general.title')}

- Configure your Backspace instance. These settings affect all users. + {t('instance.general.description')}
{/* Instance Name */}
-
Instance Name
-

The name shown on the login page and to federated instances.

+
{t('instance.general.name')}
+

{t('instance.general.name.description')}

-
Discovery
+
{t('instance.general.discovery')}
@@ -100,16 +102,16 @@ export function GeneralPanel() { {/* GIF Search */}
-
GIF Search
+
{t('instance.general.gif')}

- Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal. + {t('instance.general.gif.description')}

{ setGifKeyDraft(e.target.value); setGifKeyDirty(true); }} - placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'} + placeholder={t(draft.gifEnabled ? 'instance.general.gif.keySaved' : 'instance.general.gif.keyPlaceholder')} className="input-standard w-full" autoComplete="off" /> @@ -117,14 +119,14 @@ export function GeneralPanel() { - {draft.gifEnabled ? 'Enabled' : 'Not configured'} + {t(draft.gifEnabled ? 'instance.general.gif.enabled' : 'instance.general.gif.notConfigured')} {draft.gifEnabled && !gifKeyDirty && ( )}
@@ -144,14 +146,14 @@ export function GeneralPanel() { onClick={handleReset} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" > - Reset + {t('instance.general.reset')}
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx index 0ac96b81..67551c13 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/BansPanel.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Avatar } from '../../ui/Avatar'; import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore'; +import { useT, type TranslationKey } from '../../../i18n'; interface Ban { spaceId: string; @@ -17,6 +18,7 @@ interface BansPanelProps { } export function BansPanel({ spaceId }: BansPanelProps) { + const t = useT(); const spaces = useSpaceStore((s) => s.spaces); const space = spaces.find((s) => s.id === spaceId); const spaceApi = getApiForOrigin(space?._instanceOrigin ?? ''); @@ -31,7 +33,7 @@ export function BansPanel({ spaceId }: BansPanelProps) { const data = await spaceApi.spaces.getBans(spaceId); setBans(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load bans'); + setError(err instanceof Error ? err.message : t('space.bans.loadFailed')); } finally { setIsLoading(false); } @@ -46,33 +48,33 @@ export function BansPanel({ spaceId }: BansPanelProps) { await spaceApi.spaces.unban(spaceId, userId); setBans((prev) => prev.filter((b) => b.userId !== userId)); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to unban user'); + setError(err instanceof Error ? err.message : t('space.bans.unbanFailed')); } }; if (isLoading) { return (
-
Loading bans...
+
{t('space.bans.loading')}
); } return (
-

Bans

+

{t('space.bans.title')}

{error && (
{error}
)} -

Banned users cannot rejoin this space until unbanned.

+

{t('space.bans.description')}

{bans.length === 0 ? ( -
No banned users
+
{t('space.bans.empty')}
) : (
- Bans ({bans.length}) + {t('space.bans.count', { count: bans.length })}
@@ -93,7 +95,7 @@ export function BansPanel({ spaceId }: BansPanelProps) {
{displayName}
- Banned by {moderatorName} on {bannedDate} + {t('space.bans.by', { moderator: moderatorName, date: bannedDate })} {ban.reason && ` — ${ban.reason}`}
@@ -102,7 +104,7 @@ export function BansPanel({ spaceId }: BansPanelProps) { onClick={() => handleUnban(ban.userId)} className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary hover:bg-surface-base rounded transition-colors flex-shrink-0" > - Unban + {t('space.bans.unban')}
); diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx index 135658bc..1c282273 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx @@ -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({ ))} {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && ( - No roles + {t('space.members.noRoles')} )}
@@ -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')} )} {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')} )} {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')}
)} @@ -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 (
-

Members

+

{t('space.members.title')}

{error && (
{error}
)} -

Manage members of this space. Click a member to edit their roles.

+

{t('space.members.description')}

- Members ({members.length}) + {t('space.members.count', { count: members.length })}
@@ -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')} />
); diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 125972da..555429d2 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -12,12 +12,14 @@ import { useTransferStore } from '../../../stores/transferStore'; import { waitForTransferAttachment } from '../../../utils/waitForTransfer'; import { getMyUserIdForOrigin } from '../../../stores/spaceStore'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; +import { useT, type TranslationKey } from '../../../i18n'; interface OverviewPanelProps { spaceId: string; } export function OverviewPanel({ spaceId }: OverviewPanelProps) { + const t = useT(); const spaces = useSpaceStore((s) => s.spaces); const updateSpace = useSpaceStore((s) => s.updateSpace); const deleteSpace = useSpaceStore((s) => s.deleteSpace); @@ -121,7 +123,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const { filename } = await waitForTransferAttachment(tid); setIconFilename(filename); } catch { - setSaveError('Failed to upload icon'); + setSaveError(t('space.overview.iconFailed')); setIconPreview(null); URL.revokeObjectURL(previewUrl); } finally { @@ -162,7 +164,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const { filename } = await waitForTransferAttachment(tid); setBannerFilename(filename); } catch { - setSaveError('Failed to upload banner'); + setSaveError(t('space.overview.bannerFailed')); setBannerPreview(null); URL.revokeObjectURL(previewUrl); } finally { @@ -204,9 +206,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { URL.revokeObjectURL(bannerPreview); setBannerPreview(null); } - addToast('Settings saved', 'success', 2000); + addToast(t('space.overview.saved'), 'success', 2000); } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save'); + setSaveError(err instanceof Error ? err.message : t('space.overview.saveFailed')); } finally { setSaving(false); } @@ -238,7 +240,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { closeModal(); navigate('/channels/@me'); } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to delete space'); + setSaveError(err instanceof Error ? err.message : t('space.overview.delete.failed')); } }; @@ -265,7 +267,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { setTransferTargetId(null); setTransferSearch(''); } catch (err) { - addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); + addToast(err instanceof Error ? err.message : t('space.overview.transfer.failed'), 'warning', 3000); } finally { setTransferring(false); } @@ -278,15 +280,15 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { return ( <>
-

Overview

+

{t('space.overview.title')}

{/* Space Identity */}
-
Space Identity
+
{t('space.overview.identity')}
{/* Space Icon */}
)}
@@ -348,7 +350,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { {/* Space Avatar Color */}
{AVATAR_COLORS.map((key) => { @@ -375,7 +377,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { {/* Space Banner */}
)}
@@ -437,7 +439,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { {/* Space Name */}
-
Danger Zone
+
{t('space.overview.danger')}
{/* Transfer Ownership */}
-

Transfer ownership to another member. You will become a regular member.

+

{t('space.overview.transfer.description')}

{showTransfer ? ( transferTargetId && transferTarget ? ( /* Confirm step */

- Transfer ownership to{' '} + {t('space.overview.transfer.confirm')}{' '} {transferTarget.user.displayName || transferTarget.user.username}?

@@ -478,14 +480,14 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors" disabled={transferring} > - Back + {t('space.overview.transfer.back')}
@@ -496,13 +498,13 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { type="text" value={transferSearch} onChange={(e) => setTransferSearch(e.target.value)} - placeholder="Search members..." + placeholder={t('space.overview.transfer.search')} className="input-search w-full" autoFocus />
{transferCandidates.length === 0 ? ( -

No members found

+

{t('space.overview.transfer.empty')}

) : ( transferCandidates.map((member) => { const avatarUrl = member.user.avatar @@ -542,7 +544,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { onClick={() => { setShowTransfer(false); setTransferSearch(''); }} className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors" > - Cancel + {t('space.overview.transfer.cancel')}
) @@ -551,7 +553,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { onClick={() => setShowTransfer(true)} className="px-4 py-2 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded transition-colors" > - Transfer Ownership + {t('space.overview.transfer.button')} )}
@@ -561,12 +563,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { {/* Delete Space */}
-

Permanently delete this space and all its data. This cannot be undone.

+

{t('space.overview.delete.description')}

@@ -581,14 +583,14 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { onClick={handleDiscard} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" > - Discard + {t('space.overview.discard')}
@@ -601,7 +603,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { onClose={() => setCropSrc(null)} imageSrc={cropSrc ?? ''} onCropComplete={handleCropComplete} - title="Crop Space Icon" + title={t('space.overview.cropIcon')} cropShape="round" aspectRatio={1} maxOutputDimension={256} @@ -612,7 +614,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { onClose={() => setBannerCropSrc(null)} imageSrc={bannerCropSrc ?? ''} onCropComplete={handleBannerCropComplete} - title="Crop Space Banner" + title={t('space.overview.cropBanner')} cropShape="rect" aspectRatio={16 / 9} maxOutputDimension={1280} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx index 234f9542..052060a2 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -4,48 +4,51 @@ import { useUIStore } from '../../../stores/uiStore'; import { api } from '../../../api/client'; import { PermissionBits, stringToPermissions, permissionsToString } from '../../../utils/permissions'; import type { Role } from '@backspace/shared'; +import { useT, type TranslationKey } from '../../../i18n'; // ─── Permission display groups ───────────────────────────────────────────── interface PermDef { bit: bigint; - label: string; + labelKey: TranslationKey; } -const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [ +/** Labels are keys from the shared `perm.*` set — the same ones the channel + * and category override editors render, so a rename lands everywhere. */ +const PERMISSION_GROUPS: { nameKey: TranslationKey; perms: PermDef[] }[] = [ { - name: 'General', + nameKey: 'perm.group.general', perms: [ - { bit: PermissionBits.ADMINISTRATOR, label: 'Administrator' }, - { bit: PermissionBits.VIEW_CHANNEL, label: 'View Channels' }, - { bit: PermissionBits.MANAGE_CHANNELS, label: 'Manage Channels' }, - { bit: PermissionBits.MANAGE_ROLES, label: 'Manage Roles' }, - { bit: PermissionBits.MANAGE_SPACE, label: 'Manage Space' }, - { bit: PermissionBits.CREATE_INVITE, label: 'Create Invite' }, - { bit: PermissionBits.KICK_MEMBERS, label: 'Kick Members' }, - { bit: PermissionBits.BAN_MEMBERS, label: 'Ban Members' }, + { bit: PermissionBits.ADMINISTRATOR, labelKey: 'perm.administrator' }, + { bit: PermissionBits.VIEW_CHANNEL, labelKey: 'perm.viewChannels' }, + { bit: PermissionBits.MANAGE_CHANNELS, labelKey: 'perm.manageChannels' }, + { bit: PermissionBits.MANAGE_ROLES, labelKey: 'perm.manageRoles' }, + { bit: PermissionBits.MANAGE_SPACE, labelKey: 'perm.manageSpace' }, + { bit: PermissionBits.CREATE_INVITE, labelKey: 'perm.createInvite' }, + { bit: PermissionBits.KICK_MEMBERS, labelKey: 'perm.kickMembers' }, + { bit: PermissionBits.BAN_MEMBERS, labelKey: 'perm.banMembers' }, ], }, { - name: 'Text', + nameKey: 'perm.group.text', perms: [ - { bit: PermissionBits.SEND_MESSAGES, label: 'Send Messages' }, - { bit: PermissionBits.MANAGE_MESSAGES, label: 'Manage Messages' }, - { bit: PermissionBits.ATTACH_FILES, label: 'Attach Files' }, - { bit: PermissionBits.READ_MESSAGE_HISTORY, label: 'Read Message History' }, - { bit: PermissionBits.ADD_REACTIONS, label: 'Add Reactions' }, + { bit: PermissionBits.SEND_MESSAGES, labelKey: 'perm.sendMessages' }, + { bit: PermissionBits.MANAGE_MESSAGES, labelKey: 'perm.manageMessages' }, + { bit: PermissionBits.ATTACH_FILES, labelKey: 'perm.attachFiles' }, + { bit: PermissionBits.READ_MESSAGE_HISTORY, labelKey: 'perm.readHistory' }, + { bit: PermissionBits.ADD_REACTIONS, labelKey: 'perm.addReactions' }, ], }, { - name: 'Voice', + nameKey: 'perm.group.voice', perms: [ - { bit: PermissionBits.CONNECT, label: 'Connect' }, - { bit: PermissionBits.SPEAK, label: 'Speak' }, - { bit: PermissionBits.MUTE_MEMBERS, label: 'Mute Members' }, - { bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' }, - { bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' }, - { bit: PermissionBits.DISCONNECT_MEMBERS, label: 'Disconnect Members' }, - { bit: PermissionBits.STREAM, label: 'Stream' }, + { bit: PermissionBits.CONNECT, labelKey: 'perm.connect' }, + { bit: PermissionBits.SPEAK, labelKey: 'perm.speak' }, + { bit: PermissionBits.MUTE_MEMBERS, labelKey: 'perm.muteMembers' }, + { bit: PermissionBits.DEAFEN_MEMBERS, labelKey: 'perm.deafenMembers' }, + { bit: PermissionBits.MOVE_MEMBERS, labelKey: 'perm.moveMembers' }, + { bit: PermissionBits.DISCONNECT_MEMBERS, labelKey: 'perm.disconnectMembers' }, + { bit: PermissionBits.STREAM, labelKey: 'perm.stream' }, ], }, ]; @@ -74,6 +77,7 @@ interface RolesPanelProps { } export function RolesPanel({ spaceId }: RolesPanelProps) { + const t = useT(); const roles = useSpaceStore((s) => s.roles); const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); @@ -101,7 +105,7 @@ export function RolesPanel({ spaceId }: RolesPanelProps) { setIsNewRole(true); setEditingRoleId(newRole.id); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create role'); + setError(err instanceof Error ? err.message : t('space.roles.createFailed')); } finally { setCreating(false); } @@ -128,7 +132,7 @@ export function RolesPanel({ spaceId }: RolesPanelProps) { return (
-

Roles

+

{t('space.roles.title')}

{error && (
{error}
)} @@ -143,13 +147,13 @@ export function RolesPanel({ spaceId }: RolesPanelProps) { - {creating ? 'Creating...' : 'Create Role'} + {creating ? t('space.roles.creating') : t('space.roles.create')}
-
Roles
-

Roles define what permissions members have. Higher roles take priority.

+
{t('space.roles.title')}
+

{t('space.roles.description')}

{sortedRoles.map((role) => { @@ -200,6 +204,7 @@ interface RoleEditViewProps { } function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: RoleEditViewProps) { + const t = useT(); const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); const roles = useSpaceStore((s) => s.roles); const isEveryone = role.id === spaceId; @@ -217,7 +222,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol const validateName = (name: string): string => { const trimmed = name.trim(); - if (!trimmed) return 'Role name cannot be empty'; + if (!trimmed) return t('space.roles.name.empty'); const isDuplicate = roles.some( (r) => r.id !== role.id && r.name.toLowerCase() === trimmed.toLowerCase() ); @@ -254,9 +259,9 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol if (hasPermChange) data.permissions = permissionsToString(draftPermissions); await api.roles.update(spaceId, role.id, data); await loadSpaceDetail(spaceId); - addToast('Role saved', 'success', 2000); + addToast(t('space.roles.saved'), 'success', 2000); } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to save role'; + const msg = err instanceof Error ? err.message : t('space.roles.saveFailed'); if (msg.includes('already exists')) { setNameError(msg); } else { @@ -288,7 +293,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol await loadSpaceDetail(spaceId); onDeleted(); } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to delete role'); + setSaveError(err instanceof Error ? err.message : t('space.roles.deleteFailed')); setConfirmDelete(false); } finally { setDeleting(false); @@ -310,7 +315,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol await loadSpaceDetail(spaceId); onCopied(newRole.id); } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to copy role'); + setSaveError(err instanceof Error ? err.message : t('space.roles.copyFailed')); } finally { setCopying(false); } @@ -327,24 +332,24 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol - Back to roles + {t('space.roles.back')}
{isNew && !hasChanges && (
- Role created — customize it below + {t('space.roles.created')}
)} {/* Identity card (Name + Color — not shown for @everyone) */} {!isEveryone && (
-
Identity
+
{t('space.roles.identity')}
{PRESET_COLORS.map((c) => ( @@ -403,9 +408,9 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol {/* Permission groups — each gets its own section card */} {PERMISSION_GROUPS.map((group) => ( -
+
- {group.name} + {t(group.nameKey)}
@@ -416,13 +421,13 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol const isInherited = !isAdminBit && hasAdmin; return (