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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user