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
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -18,23 +18,23 @@ import {
|
||||
type ChannelPermissionDef = Omit<PermissionDef, 'label'> & { 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. */
|
||||
|
||||
@@ -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 <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||
if (!draft) return <div className="text-sm text-txt-tertiary">{t('instance.general.loading')}</div>;
|
||||
|
||||
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 (
|
||||
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
|
||||
<h2 className="text-lg font-semibold text-txt-primary">General</h2>
|
||||
<h2 className="text-lg font-semibold text-txt-primary">{t('instance.general.title')}</h2>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Configure your Backspace instance. These settings affect all users.
|
||||
{t('instance.general.description')}
|
||||
</div>
|
||||
|
||||
{/* Instance Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Instance Name</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">The name shown on the login page and to federated instances.</p>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('instance.general.name')}</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">{t('instance.general.name.description')}</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<input
|
||||
type="text"
|
||||
@@ -86,12 +88,12 @@ export function GeneralPanel() {
|
||||
|
||||
{/* Discovery */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Discovery</div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('instance.general.discovery')}</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary">Space Discovery</div>
|
||||
<div className="text-xs text-txt-tertiary mt-0.5">Allow spaces to appear in the public Explore page</div>
|
||||
<div className="text-sm font-medium text-txt-primary">{t('instance.general.discovery.title')}</div>
|
||||
<div className="text-xs text-txt-tertiary mt-0.5">{t('instance.general.discovery.description')}</div>
|
||||
</div>
|
||||
<Toggle enabled={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} />
|
||||
</label>
|
||||
@@ -100,16 +102,16 @@ export function GeneralPanel() {
|
||||
|
||||
{/* GIF Search */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">GIF Search</div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('instance.general.gif')}</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">
|
||||
Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal.
|
||||
{t('instance.general.gif.description')}
|
||||
</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={gifKeyDirty ? gifKeyDraft : ''}
|
||||
onChange={(e) => { 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() {
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${
|
||||
draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
|
||||
}`}>
|
||||
{draft.gifEnabled ? 'Enabled' : 'Not configured'}
|
||||
{t(draft.gifEnabled ? 'instance.general.gif.enabled' : 'instance.general.gif.notConfigured')}
|
||||
</span>
|
||||
{draft.gifEnabled && !gifKeyDirty && (
|
||||
<button
|
||||
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
|
||||
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
|
||||
>
|
||||
Clear key
|
||||
{t('instance.general.gif.clearKey')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('instance.general.saving') : t('instance.general.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-txt-tertiary text-sm">Loading bans...</div>
|
||||
<div className="text-txt-tertiary text-sm">{t('space.bans.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Bans</h2>
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.bans.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">Banned users cannot rejoin this space until unbanned.</p>
|
||||
<p className="text-xs text-txt-tertiary">{t('space.bans.description')}</p>
|
||||
|
||||
{bans.length === 0 ? (
|
||||
<div className="text-center py-8 text-txt-tertiary text-sm">No banned users</div>
|
||||
<div className="text-center py-8 text-txt-tertiary text-sm">{t('space.bans.empty')}</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Bans ({bans.length})
|
||||
{t('space.bans.count', { count: bans.length })}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
@@ -93,7 +95,7 @@ export function BansPanel({ spaceId }: BansPanelProps) {
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
Banned by {moderatorName} on {bannedDate}
|
||||
{t('space.bans.by', { moderator: moderatorName, date: bannedDate })}
|
||||
{ban.reason && ` — ${ban.reason}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Overview</h2>
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.overview.title')}</h2>
|
||||
{/* Space Identity */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Space Identity</div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.overview.identity')}</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Space Icon */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Icon
|
||||
{t('space.overview.icon')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -299,7 +301,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
>
|
||||
{displayIconSrc ? (
|
||||
<>
|
||||
<img src={displayIconSrc} alt="Space icon" className="w-full h-full object-cover" />
|
||||
<img src={displayIconSrc} alt={t('space.overview.iconAlt')} className="w-full h-full object-cover" />
|
||||
{canManageSpace && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -339,7 +341,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
onClick={handleRemoveIcon}
|
||||
className="text-xs text-txt-tertiary hover:text-txt-danger transition-colors"
|
||||
>
|
||||
Remove
|
||||
{t('space.overview.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -348,7 +350,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
{/* Space Avatar Color */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Icon Color
|
||||
{t('space.overview.iconColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{AVATAR_COLORS.map((key) => {
|
||||
@@ -375,7 +377,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
{/* Space Banner */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Banner
|
||||
{t('space.overview.banner')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -388,7 +390,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
>
|
||||
{displayBannerSrc ? (
|
||||
<>
|
||||
<img src={displayBannerSrc} alt="Space banner" className="w-full h-full object-cover" />
|
||||
<img src={displayBannerSrc} alt={t('space.overview.bannerAlt')} className="w-full h-full object-cover" />
|
||||
{canManageSpace && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -403,7 +405,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
<svg className="w-6 h-6 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-[11px]">Upload banner (16:9)</span>
|
||||
<span className="text-[11px]">{t('space.overview.bannerUpload')}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
@@ -428,7 +430,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
onClick={handleRemoveBanner}
|
||||
className="text-xs text-txt-tertiary hover:text-txt-danger transition-colors self-start"
|
||||
>
|
||||
Remove banner
|
||||
{t('space.overview.bannerRemove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -437,7 +439,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
{/* Space Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Name
|
||||
{t('space.overview.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -457,18 +459,18 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
{/* Danger Zone */}
|
||||
{isOwner && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-danger uppercase tracking-wider mb-1.5">Danger Zone</div>
|
||||
<div className="text-[11px] font-semibold text-txt-danger uppercase tracking-wider mb-1.5">{t('space.overview.danger')}</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Transfer Ownership */}
|
||||
<div>
|
||||
<p className="text-xs text-txt-tertiary mb-3">Transfer ownership to another member. You will become a regular member.</p>
|
||||
<p className="text-xs text-txt-tertiary mb-3">{t('space.overview.transfer.description')}</p>
|
||||
{showTransfer ? (
|
||||
transferTargetId && transferTarget ? (
|
||||
/* Confirm step */
|
||||
<div className="space-y-3">
|
||||
<div className="p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/20">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
Transfer ownership to{' '}
|
||||
{t('space.overview.transfer.confirm')}{' '}
|
||||
<span className="font-semibold text-txt-primary">{transferTarget.user.displayName || transferTarget.user.username}</span>?
|
||||
</p>
|
||||
</div>
|
||||
@@ -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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTransfer}
|
||||
disabled={transferring}
|
||||
className="px-3 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Transfer'}
|
||||
{transferring ? t('space.overview.transfer.transferring') : t('space.overview.transfer.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
/>
|
||||
<div className="max-h-[160px] overflow-y-auto space-y-0.5">
|
||||
{transferCandidates.length === 0 ? (
|
||||
<p className="text-xs text-txt-tertiary text-center py-3">No members found</p>
|
||||
<p className="text-xs text-txt-tertiary text-center py-3">{t('space.overview.transfer.empty')}</p>
|
||||
) : (
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -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')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -561,12 +563,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
|
||||
{/* Delete Space */}
|
||||
<div>
|
||||
<p className="text-xs text-txt-tertiary mb-3">Permanently delete this space and all its data. This cannot be undone.</p>
|
||||
<p className="text-xs text-txt-tertiary mb-3">{t('space.overview.delete.description')}</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
|
||||
{confirmDelete ? t('space.overview.delete.confirm') : t('space.overview.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || uploadingIcon || uploadingBanner || !spaceName.trim()}
|
||||
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"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('space.overview.saving') : t('space.overview.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Roles</h2>
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.roles.title')}</h2>
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||
)}
|
||||
@@ -143,13 +147,13 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{creating ? 'Creating...' : 'Create Role'}
|
||||
{creating ? t('space.roles.creating') : t('space.roles.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Roles</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Roles define what permissions members have. Higher roles take priority.</p>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.roles.title')}</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">{t('space.roles.description')}</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{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
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to roles
|
||||
{t('space.roles.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isNew && !hasChanges && (
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">
|
||||
Role created — customize it below
|
||||
{t('space.roles.created')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity card (Name + Color — not shown for @everyone) */}
|
||||
{!isEveryone && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Identity</div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.roles.identity')}</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Role Name
|
||||
{t('space.roles.name')}
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
@@ -363,7 +368,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Role Color
|
||||
{t('space.roles.color')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{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) => (
|
||||
<div key={group.name}>
|
||||
<div key={group.nameKey}>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
{group.name}
|
||||
{t(group.nameKey)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<div className="space-y-1">
|
||||
@@ -416,13 +421,13 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol
|
||||
const isInherited = !isAdminBit && hasAdmin;
|
||||
return (
|
||||
<label
|
||||
key={perm.label}
|
||||
key={perm.labelKey}
|
||||
className={`flex items-center justify-between py-1.5 px-2 rounded cursor-pointer group/perm ${
|
||||
isInherited ? 'opacity-50 cursor-default' : 'hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm ${isAdminBit ? 'text-txt-danger font-medium' : 'text-txt-primary'}`}>
|
||||
{perm.label}
|
||||
{t(perm.labelKey)}
|
||||
</span>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
@@ -461,14 +466,14 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol
|
||||
onClick={handleDiscard}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
{t('space.roles.discard')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (!isEveryone && !draftName.trim()) || !!nameError}
|
||||
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"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('space.roles.saving') : t('space.roles.save')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -480,7 +485,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol
|
||||
disabled={copying}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-full text-txt-secondary hover:bg-interactive-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copying ? 'Copying...' : 'Copy Role'}
|
||||
{copying ? t('space.roles.copying') : t('space.roles.copy')}
|
||||
</button>
|
||||
{!isEveryone && (
|
||||
<>
|
||||
@@ -494,7 +499,7 @@ function RoleEditView({ role, spaceId, isNew, onBack, onDeleted, onCopied }: Rol
|
||||
: 'text-accent-rose hover:bg-accent-rose/10'
|
||||
}`}
|
||||
>
|
||||
{deleting ? 'Deleting...' : confirmDelete ? 'Confirm?' : 'Delete Role'}
|
||||
{deleting ? t('space.roles.deleting') : confirmDelete ? t('space.roles.deleteConfirm') : t('space.roles.delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -255,20 +255,6 @@ export const en = {
|
||||
'channel.delete.irreversible': 'This action cannot be undone.',
|
||||
'channel.delete.failed': 'Failed to delete channel',
|
||||
|
||||
// Channel permissions — override editor labels
|
||||
'channel.perm.viewChannel': 'View Channel',
|
||||
'channel.perm.sendMessages': 'Send Messages',
|
||||
'channel.perm.manageMessages': 'Manage Messages',
|
||||
'channel.perm.attachFiles': 'Attach Files',
|
||||
'channel.perm.readHistory': 'Read Message History',
|
||||
'channel.perm.addReactions': 'Add Reactions',
|
||||
'channel.perm.connect': 'Connect',
|
||||
'channel.perm.speak': 'Speak',
|
||||
'channel.perm.stream': 'Stream',
|
||||
'channel.perm.muteMembers': 'Mute Members',
|
||||
'channel.perm.deafenMembers': 'Deafen Members',
|
||||
'channel.perm.moveMembers': 'Move Members',
|
||||
'channel.perm.disconnectMembers': 'Disconnect Members',
|
||||
// Friends — tabs and lists
|
||||
'friends.title': 'Friends',
|
||||
'friends.tab.online': 'Online',
|
||||
@@ -665,6 +651,159 @@ export const en = {
|
||||
'error.title': 'Something went wrong',
|
||||
'error.tryAgain': 'Try Again',
|
||||
'error.reload': 'Reload Page',
|
||||
// Permission labels — shared by the roles panel and the channel/category
|
||||
// override editors, so a rename lands in all three at once.
|
||||
'perm.group.general': 'General',
|
||||
'perm.group.text': 'Text',
|
||||
'perm.group.voice': 'Voice',
|
||||
'perm.administrator': 'Administrator',
|
||||
'perm.viewChannel': 'View Channel',
|
||||
'perm.viewChannels': 'View Channels',
|
||||
'perm.manageChannels': 'Manage Channels',
|
||||
'perm.manageRoles': 'Manage Roles',
|
||||
'perm.manageSpace': 'Manage Space',
|
||||
'perm.createInvite': 'Create Invite',
|
||||
'perm.kickMembers': 'Kick Members',
|
||||
'perm.banMembers': 'Ban Members',
|
||||
'perm.sendMessages': 'Send Messages',
|
||||
'perm.manageMessages': 'Manage Messages',
|
||||
'perm.attachFiles': 'Attach Files',
|
||||
'perm.readHistory': 'Read Message History',
|
||||
'perm.addReactions': 'Add Reactions',
|
||||
'perm.connect': 'Connect',
|
||||
'perm.speak': 'Speak',
|
||||
'perm.stream': 'Stream',
|
||||
'perm.muteMembers': 'Mute Members',
|
||||
'perm.deafenMembers': 'Deafen Members',
|
||||
'perm.moveMembers': 'Move Members',
|
||||
'perm.disconnectMembers': 'Disconnect Members',
|
||||
|
||||
// Space settings — overview
|
||||
'space.overview.title': 'Overview',
|
||||
'space.overview.identity': 'Space Identity',
|
||||
'space.overview.icon': 'Space Icon',
|
||||
'space.overview.iconAlt': 'Space icon',
|
||||
'space.overview.iconColor': 'Icon Color',
|
||||
'space.overview.banner': 'Space Banner',
|
||||
'space.overview.bannerAlt': 'Space banner',
|
||||
'space.overview.bannerUpload': 'Upload banner (16:9)',
|
||||
'space.overview.bannerRemove': 'Remove banner',
|
||||
'space.overview.remove': 'Remove',
|
||||
'space.overview.name': 'Space Name',
|
||||
'space.overview.save': 'Save',
|
||||
'space.overview.saving': 'Saving...',
|
||||
'space.overview.discard': 'Discard',
|
||||
'space.overview.saved': 'Settings saved',
|
||||
'space.overview.saveFailed': 'Failed to save',
|
||||
'space.overview.iconFailed': 'Failed to upload icon',
|
||||
'space.overview.bannerFailed': 'Failed to upload banner',
|
||||
'space.overview.cropIcon': 'Crop Space Icon',
|
||||
'space.overview.cropBanner': 'Crop Space Banner',
|
||||
'space.overview.danger': 'Danger Zone',
|
||||
'space.overview.transfer.description': 'Transfer ownership to another member. You will become a regular member.',
|
||||
'space.overview.transfer.button': 'Transfer Ownership',
|
||||
'space.overview.transfer.confirm': 'Transfer ownership to',
|
||||
'space.overview.transfer.search': 'Search members...',
|
||||
'space.overview.transfer.empty': 'No members found',
|
||||
'space.overview.transfer.back': 'Back',
|
||||
'space.overview.transfer.cancel': 'Cancel',
|
||||
'space.overview.transfer.submit': 'Transfer',
|
||||
'space.overview.transfer.transferring': 'Transferring...',
|
||||
'space.overview.transfer.failed': 'Failed to transfer ownership',
|
||||
'space.overview.delete.description': 'Permanently delete this space and all its data. This cannot be undone.',
|
||||
'space.overview.delete': 'Delete Space',
|
||||
'space.overview.delete.confirm': 'Click again to confirm deletion',
|
||||
'space.overview.delete.failed': 'Failed to delete space',
|
||||
|
||||
// Space settings — members and bans
|
||||
'space.members.title': 'Members',
|
||||
'space.members.description': 'Manage members of this space. Click a member to edit their roles.',
|
||||
'space.members.count': 'Members ({count})',
|
||||
'space.members.noRoles': 'No roles',
|
||||
'space.members.save': 'Save',
|
||||
'space.members.cancel': 'Cancel',
|
||||
'space.members.kick': 'Kick',
|
||||
'space.members.ban': 'Ban',
|
||||
'space.members.kick.title': 'Kick {name}',
|
||||
'space.members.kick.description': 'They can rejoin with an invite link.',
|
||||
'space.members.ban.title': 'Ban {name}',
|
||||
'space.members.ban.description': 'They will be permanently banned from this space until unbanned.',
|
||||
'space.members.rolesFailed': 'Failed to update roles',
|
||||
'space.members.kickFailed': 'Failed to kick member',
|
||||
'space.members.banFailed': 'Failed to ban member',
|
||||
'space.bans.title': 'Bans',
|
||||
'space.bans.count': 'Bans ({count})',
|
||||
'space.bans.description': 'Banned users cannot rejoin this space until unbanned.',
|
||||
'space.bans.loading': 'Loading bans...',
|
||||
'space.bans.empty': 'No banned users',
|
||||
'space.bans.by': 'Banned by {moderator} on {date}',
|
||||
'space.bans.unban': 'Unban',
|
||||
'space.bans.loadFailed': 'Failed to load bans',
|
||||
'space.bans.unbanFailed': 'Failed to unban user',
|
||||
|
||||
// Space settings — roles
|
||||
'space.roles.title': 'Roles',
|
||||
'space.roles.description': 'Roles define what permissions members have. Higher roles take priority.',
|
||||
'space.roles.create': 'Create Role',
|
||||
'space.roles.creating': 'Creating...',
|
||||
'space.roles.createFailed': 'Failed to create role',
|
||||
'space.roles.created': 'Role created — customize it below',
|
||||
'space.roles.back': 'Back to roles',
|
||||
'space.roles.identity': 'Identity',
|
||||
'space.roles.name': 'Role Name',
|
||||
'space.roles.name.empty': 'Role name cannot be empty',
|
||||
'space.roles.color': 'Role Color',
|
||||
'space.roles.save': 'Save',
|
||||
'space.roles.saving': 'Saving...',
|
||||
'space.roles.saved': 'Role saved',
|
||||
'space.roles.saveFailed': 'Failed to save role',
|
||||
'space.roles.discard': 'Discard',
|
||||
'space.roles.copy': 'Copy Role',
|
||||
'space.roles.copying': 'Copying...',
|
||||
'space.roles.copyFailed': 'Failed to copy role',
|
||||
'space.roles.delete': 'Delete Role',
|
||||
'space.roles.deleting': 'Deleting...',
|
||||
'space.roles.deleteConfirm': 'Confirm?',
|
||||
'space.roles.deleteFailed': 'Failed to delete role',
|
||||
|
||||
// Category settings
|
||||
'category.settings.title': 'Category Settings',
|
||||
'category.tab.overview': 'Overview',
|
||||
'category.tab.permissions': 'Permissions',
|
||||
'category.label': 'Category',
|
||||
'category.private.title': 'Private Category',
|
||||
'category.private.description': 'Only selected members and roles will be able to view channels in this category.',
|
||||
'category.private.note': 'This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it.',
|
||||
'category.private.loadFailed': 'Failed to load category overrides',
|
||||
'category.private.updateFailed': 'Failed to update category privacy',
|
||||
'category.renameFailed': 'Failed to rename category',
|
||||
'category.danger': 'Danger Zone',
|
||||
'category.delete': 'Delete Category',
|
||||
'category.delete.title': 'Delete "{name}"?',
|
||||
'category.delete.body': 'This will permanently delete the {name} category. Channels inside it will be moved to the top level. This action cannot be undone.',
|
||||
'category.delete.failed': 'Failed to delete category',
|
||||
|
||||
// Instance settings — general
|
||||
'instance.general.title': 'General',
|
||||
'instance.general.description': 'Configure your Backspace instance. These settings affect all users.',
|
||||
'instance.general.loading': 'Loading settings...',
|
||||
'instance.general.name': 'Instance Name',
|
||||
'instance.general.name.description': 'The name shown on the login page and to federated instances.',
|
||||
'instance.general.discovery': 'Discovery',
|
||||
'instance.general.discovery.title': 'Space Discovery',
|
||||
'instance.general.discovery.description': 'Allow spaces to appear in the public Explore page',
|
||||
'instance.general.gif': 'GIF Search',
|
||||
'instance.general.gif.description': 'Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal.',
|
||||
'instance.general.gif.keySaved': 'Key saved — enter new key to replace',
|
||||
'instance.general.gif.keyPlaceholder': 'Klipy API key',
|
||||
'instance.general.gif.enabled': 'Enabled',
|
||||
'instance.general.gif.notConfigured': 'Not configured',
|
||||
'instance.general.gif.clearKey': 'Clear key',
|
||||
'instance.general.save': 'Save',
|
||||
'instance.general.saving': 'Saving...',
|
||||
'instance.general.reset': 'Reset',
|
||||
'instance.general.saved': 'Settings saved',
|
||||
'instance.general.saveFailed': 'Failed to save',
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
@@ -254,20 +254,6 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'channel.delete.irreversible': 'Não dá para desfazer.',
|
||||
'channel.delete.failed': 'Não foi possível excluir o canal',
|
||||
|
||||
// Permissões do canal — rótulos do editor de exceções
|
||||
'channel.perm.viewChannel': 'Ver canal',
|
||||
'channel.perm.sendMessages': 'Enviar mensagens',
|
||||
'channel.perm.manageMessages': 'Gerenciar mensagens',
|
||||
'channel.perm.attachFiles': 'Anexar arquivos',
|
||||
'channel.perm.readHistory': 'Ler histórico de mensagens',
|
||||
'channel.perm.addReactions': 'Adicionar reações',
|
||||
'channel.perm.connect': 'Conectar',
|
||||
'channel.perm.speak': 'Falar',
|
||||
'channel.perm.stream': 'Transmitir',
|
||||
'channel.perm.muteMembers': 'Silenciar membros',
|
||||
'channel.perm.deafenMembers': 'Ensurdecer membros',
|
||||
'channel.perm.moveMembers': 'Mover membros',
|
||||
'channel.perm.disconnectMembers': 'Desconectar membros',
|
||||
// Amigos — abas e listas
|
||||
'friends.title': 'Amigos',
|
||||
'friends.tab.online': 'Online',
|
||||
@@ -664,4 +650,157 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'error.title': 'Alguma coisa deu errado',
|
||||
'error.tryAgain': 'Tentar de novo',
|
||||
'error.reload': 'Recarregar a página',
|
||||
// Rótulos de permissão — usados pelo painel de cargos e pelos editores de
|
||||
// exceção de canal e categoria, então renomear aqui vale nos três.
|
||||
'perm.group.general': 'Geral',
|
||||
'perm.group.text': 'Texto',
|
||||
'perm.group.voice': 'Voz',
|
||||
'perm.administrator': 'Administrador',
|
||||
'perm.viewChannel': 'Ver canal',
|
||||
'perm.viewChannels': 'Ver canais',
|
||||
'perm.manageChannels': 'Gerenciar canais',
|
||||
'perm.manageRoles': 'Gerenciar cargos',
|
||||
'perm.manageSpace': 'Gerenciar servidor',
|
||||
'perm.createInvite': 'Criar convite',
|
||||
'perm.kickMembers': 'Expulsar membros',
|
||||
'perm.banMembers': 'Banir membros',
|
||||
'perm.sendMessages': 'Enviar mensagens',
|
||||
'perm.manageMessages': 'Gerenciar mensagens',
|
||||
'perm.attachFiles': 'Anexar arquivos',
|
||||
'perm.readHistory': 'Ler histórico de mensagens',
|
||||
'perm.addReactions': 'Adicionar reações',
|
||||
'perm.connect': 'Conectar',
|
||||
'perm.speak': 'Falar',
|
||||
'perm.stream': 'Transmitir',
|
||||
'perm.muteMembers': 'Silenciar membros',
|
||||
'perm.deafenMembers': 'Ensurdecer membros',
|
||||
'perm.moveMembers': 'Mover membros',
|
||||
'perm.disconnectMembers': 'Desconectar membros',
|
||||
|
||||
// Configurações do servidor — visão geral
|
||||
'space.overview.title': 'Visão geral',
|
||||
'space.overview.identity': 'Identidade do servidor',
|
||||
'space.overview.icon': 'Ícone do servidor',
|
||||
'space.overview.iconAlt': 'Ícone do servidor',
|
||||
'space.overview.iconColor': 'Cor do ícone',
|
||||
'space.overview.banner': 'Banner do servidor',
|
||||
'space.overview.bannerAlt': 'Banner do servidor',
|
||||
'space.overview.bannerUpload': 'Enviar banner (16:9)',
|
||||
'space.overview.bannerRemove': 'Remover banner',
|
||||
'space.overview.remove': 'Remover',
|
||||
'space.overview.name': 'Nome do servidor',
|
||||
'space.overview.save': 'Salvar',
|
||||
'space.overview.saving': 'Salvando...',
|
||||
'space.overview.discard': 'Descartar',
|
||||
'space.overview.saved': 'Configurações salvas',
|
||||
'space.overview.saveFailed': 'Não foi possível salvar',
|
||||
'space.overview.iconFailed': 'Não foi possível enviar o ícone',
|
||||
'space.overview.bannerFailed': 'Não foi possível enviar o banner',
|
||||
'space.overview.cropIcon': 'Recortar o ícone do servidor',
|
||||
'space.overview.cropBanner': 'Recortar o banner do servidor',
|
||||
'space.overview.danger': 'Zona de perigo',
|
||||
'space.overview.transfer.description': 'Passe a propriedade para outro membro. Você vira um membro comum.',
|
||||
'space.overview.transfer.button': 'Transferir propriedade',
|
||||
'space.overview.transfer.confirm': 'Transferir a propriedade para',
|
||||
'space.overview.transfer.search': 'Buscar membros...',
|
||||
'space.overview.transfer.empty': 'Nenhum membro encontrado',
|
||||
'space.overview.transfer.back': 'Voltar',
|
||||
'space.overview.transfer.cancel': 'Cancelar',
|
||||
'space.overview.transfer.submit': 'Transferir',
|
||||
'space.overview.transfer.transferring': 'Transferindo...',
|
||||
'space.overview.transfer.failed': 'Não foi possível transferir a propriedade',
|
||||
'space.overview.delete.description': 'Apaga este servidor e todos os dados dele para sempre. Não dá para desfazer.',
|
||||
'space.overview.delete': 'Excluir servidor',
|
||||
'space.overview.delete.confirm': 'Clique de novo para confirmar',
|
||||
'space.overview.delete.failed': 'Não foi possível excluir o servidor',
|
||||
|
||||
// Configurações do servidor — membros e banimentos
|
||||
'space.members.title': 'Membros',
|
||||
'space.members.description': 'Gerencie os membros do servidor. Clique num membro para editar os cargos dele.',
|
||||
'space.members.count': 'Membros ({count})',
|
||||
'space.members.noRoles': 'Sem cargo',
|
||||
'space.members.save': 'Salvar',
|
||||
'space.members.cancel': 'Cancelar',
|
||||
'space.members.kick': 'Expulsar',
|
||||
'space.members.ban': 'Banir',
|
||||
'space.members.kick.title': 'Expulsar {name}',
|
||||
'space.members.kick.description': 'A pessoa pode voltar com um link de convite.',
|
||||
'space.members.ban.title': 'Banir {name}',
|
||||
'space.members.ban.description': 'A pessoa fica banida deste servidor até alguém desfazer o banimento.',
|
||||
'space.members.rolesFailed': 'Não foi possível atualizar os cargos',
|
||||
'space.members.kickFailed': 'Não foi possível expulsar o membro',
|
||||
'space.members.banFailed': 'Não foi possível banir o membro',
|
||||
'space.bans.title': 'Banimentos',
|
||||
'space.bans.count': 'Banimentos ({count})',
|
||||
'space.bans.description': 'Quem está banido não consegue voltar até alguém desfazer o banimento.',
|
||||
'space.bans.loading': 'Carregando banimentos...',
|
||||
'space.bans.empty': 'Ninguém banido',
|
||||
'space.bans.by': 'Banido por {moderator} em {date}',
|
||||
'space.bans.unban': 'Desfazer banimento',
|
||||
'space.bans.loadFailed': 'Não foi possível carregar os banimentos',
|
||||
'space.bans.unbanFailed': 'Não foi possível desfazer o banimento',
|
||||
|
||||
// Configurações do servidor — cargos
|
||||
'space.roles.title': 'Cargos',
|
||||
'space.roles.description': 'Os cargos definem o que cada membro pode fazer. Cargos mais altos têm prioridade.',
|
||||
'space.roles.create': 'Criar cargo',
|
||||
'space.roles.creating': 'Criando...',
|
||||
'space.roles.createFailed': 'Não foi possível criar o cargo',
|
||||
'space.roles.created': 'Cargo criado — ajuste abaixo',
|
||||
'space.roles.back': 'Voltar aos cargos',
|
||||
'space.roles.identity': 'Identidade',
|
||||
'space.roles.name': 'Nome do cargo',
|
||||
'space.roles.name.empty': 'O nome do cargo não pode ficar vazio',
|
||||
'space.roles.color': 'Cor do cargo',
|
||||
'space.roles.save': 'Salvar',
|
||||
'space.roles.saving': 'Salvando...',
|
||||
'space.roles.saved': 'Cargo salvo',
|
||||
'space.roles.saveFailed': 'Não foi possível salvar o cargo',
|
||||
'space.roles.discard': 'Descartar',
|
||||
'space.roles.copy': 'Duplicar cargo',
|
||||
'space.roles.copying': 'Duplicando...',
|
||||
'space.roles.copyFailed': 'Não foi possível duplicar o cargo',
|
||||
'space.roles.delete': 'Excluir cargo',
|
||||
'space.roles.deleting': 'Excluindo...',
|
||||
'space.roles.deleteConfirm': 'Confirma?',
|
||||
'space.roles.deleteFailed': 'Não foi possível excluir o cargo',
|
||||
|
||||
// Configurações da categoria
|
||||
'category.settings.title': 'Configurações da categoria',
|
||||
'category.tab.overview': 'Visão geral',
|
||||
'category.tab.permissions': 'Permissões',
|
||||
'category.label': 'Categoria',
|
||||
'category.private.title': 'Categoria privada',
|
||||
'category.private.description': 'Só os membros e cargos escolhidos vão ver os canais desta categoria.',
|
||||
'category.private.note': 'Esta categoria fica escondida de quem não tem acesso explícito. Os canais dentro dela herdam essa restrição, a menos que definam a sua própria.',
|
||||
'category.private.loadFailed': 'Não foi possível carregar as permissões da categoria',
|
||||
'category.private.updateFailed': 'Não foi possível alterar a privacidade da categoria',
|
||||
'category.renameFailed': 'Não foi possível renomear a categoria',
|
||||
'category.danger': 'Zona de perigo',
|
||||
'category.delete': 'Excluir categoria',
|
||||
'category.delete.title': 'Excluir "{name}"?',
|
||||
'category.delete.body': 'Isto apaga a categoria {name} para sempre. Os canais dentro dela vão para o nível de cima. Não dá para desfazer.',
|
||||
'category.delete.failed': 'Não foi possível excluir a categoria',
|
||||
|
||||
// Configurações da instância — geral
|
||||
'instance.general.title': 'Geral',
|
||||
'instance.general.description': 'Configure a sua instância do Backspace. Estas opções valem para todo mundo.',
|
||||
'instance.general.loading': 'Carregando configurações...',
|
||||
'instance.general.name': 'Nome da instância',
|
||||
'instance.general.name.description': 'O nome que aparece na tela de login e para as instâncias federadas.',
|
||||
'instance.general.discovery': 'Descoberta',
|
||||
'instance.general.discovery.title': 'Descoberta de servidores',
|
||||
'instance.general.discovery.description': 'Permite que os servidores apareçam na página pública do Explorar',
|
||||
'instance.general.gif': 'Busca de GIFs',
|
||||
'instance.general.gif.description': 'Liga a busca de GIFs pelo Klipy. Pegue uma chave de API gratuita no portal de desenvolvedores do Klipy.',
|
||||
'instance.general.gif.keySaved': 'Chave salva — digite outra para substituir',
|
||||
'instance.general.gif.keyPlaceholder': 'Chave de API do Klipy',
|
||||
'instance.general.gif.enabled': 'Ligado',
|
||||
'instance.general.gif.notConfigured': 'Não configurado',
|
||||
'instance.general.gif.clearKey': 'Apagar a chave',
|
||||
'instance.general.save': 'Salvar',
|
||||
'instance.general.saving': 'Salvando...',
|
||||
'instance.general.reset': 'Desfazer',
|
||||
'instance.general.saved': 'Configurações salvas',
|
||||
'instance.general.saveFailed': 'Não foi possível salvar',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user