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 (