refactor: unify settings into tabbed modal with UI polish

Split monolithic UserSettings into AccountPanel, VoicePanel, ConnectionsPanel,
and InstancePanel tabs. Remove standalone InstanceSettings modal. Add reusable
Toggle component fixing size deformation, color inconsistency (green→purple),
and flex-shrink issues. Fix custom status clearing by always sending the field
to the server. Wrap Log Out button in glass bubble. Add subtle card depth with
borders.
This commit is contained in:
Jannis Braun
2026-03-10 03:57:01 +01:00
parent 3d37c70e50
commit 4d9454382c
13 changed files with 344 additions and 285 deletions
@@ -1,53 +0,0 @@
import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { GeneralPanel } from './instanceSettingsPanels/GeneralPanel';
import { StreamingPanel } from './instanceSettingsPanels/StreamingPanel';
export function InstanceSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits);
const [tab, setTab] = useState<'general' | 'streaming'>('general');
const isOpen = activeModal === 'instanceSettings';
useEffect(() => {
if (isOpen) {
fetchInstanceSettings();
fetchStreamingLimits();
}
}, [isOpen, fetchInstanceSettings, fetchStreamingLimits]);
const tabClass = (t: typeof tab) =>
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`;
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Instance Settings" maxWidth="max-w-xl">
<div className="flex gap-4 h-[min(460px,65vh)]">
{/* 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('general')} className={tabClass('general')}>
General
</button>
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
Streaming
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'general' && <GeneralPanel />}
{tab === 'streaming' && <StreamingPanel />}
</div>
</div>
</Modal>
);
}
@@ -287,8 +287,8 @@ export function SpaceSettingsModal() {
}`;
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4 h-[min(460px,65vh)]">
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-2xl">
<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">
@@ -1,203 +1,89 @@
import React, { useState } from 'react';
import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { ConnectedInstances } from './ConnectedInstances';
import { AccountPanel } from './settingsPanels/AccountPanel';
import { VoicePanel } from './settingsPanels/VoicePanel';
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
import { InstancePanel } from './settingsPanels/InstancePanel';
type SettingsTab = 'account' | 'voice' | 'connections' | 'instance';
export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const isAdmin = useAuthStore((s) => s.user?.isAdmin);
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [status, setStatus] = useState(user?.status ?? 'online');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation);
const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl);
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
const [tab, setTab] = useState<SettingsTab>('account');
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
status: status as any,
} as any);
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsLoading(false);
// Deep-linking: read modalData.tab when opening
useEffect(() => {
if (isOpen) {
const requested = modalData.tab as SettingsTab | undefined;
if (requested && ['account', 'voice', 'connections', 'instance'].includes(requested)) {
// Only allow instance tab for admins
if (requested === 'instance' && !isAdmin) {
setTab('account');
} else {
setTab(requested);
}
} else {
setTab('account');
}
}
};
}, [isOpen, modalData.tab, isAdmin]);
const handleLogout = () => {
logout();
closeModal();
};
if (!user) return null;
const tabClass = (t: SettingsTab) =>
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`;
return (
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg">
<div className="space-y-5">
{/* Profile preview */}
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg">
<Avatar
src={user.avatar}
name={user.displayName ?? user.username}
size={64}
status={user.status}
userId={user.homeUserId ?? user.id}
/>
<div>
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
<div className="text-txt-tertiary text-sm">@{user.username}</div>
{user.customStatus && (
<div className="text-txt-secondary text-sm mt-1">{user.customStatus}</div>
<Modal isOpen={isOpen} onClose={closeModal} title="Settings" maxWidth="max-w-2xl">
<div className="flex gap-4 h-[min(520px,70vh)]">
{/* Sidebar */}
<div className="w-32 flex-shrink-0 flex flex-col gap-2">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
<button onClick={() => setTab('account')} className={tabClass('account')}>
Account
</button>
<button onClick={() => setTab('voice')} className={tabClass('voice')}>
Voice
</button>
<button onClick={() => setTab('connections')} className={tabClass('connections')}>
Connections
</button>
{isAdmin && (
<button onClick={() => setTab('instance')} className={tabClass('instance')}>
Instance
</button>
)}
</div>
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
{success && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
)}
{/* Profile section card */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Profile</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">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
>
<option value="online">Online</option>
<option value="idle">Idle</option>
<option value="dnd">Do Not Disturb</option>
</select>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
/>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="What are you up to?"
/>
</div>
<div className="glass-bubble rounded-lg p-1.5">
<button
onClick={handleLogout}
className="w-full px-2.5 py-1.5 rounded text-sm text-txt-danger hover:bg-accent-rose/10 transition-colors text-left"
>
Log Out
</button>
</div>
</div>
{/* Voice Processing section card */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Processing
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) filters keyboard, fans, and background noise</div>
</div>
<button
onClick={() => setRnnoiseEnabled(!rnnoiseEnabled)}
className={`relative w-10 h-5 rounded-full transition-colors ${rnnoiseEnabled ? 'bg-status-online' : 'bg-surface-input'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${rnnoiseEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Echo Cancellation</div>
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
</div>
<button
onClick={() => setEchoCancellation(!echoCancellation)}
className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-status-online' : 'bg-surface-input'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${echoCancellation ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Auto Gain Control</div>
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume can cause voice ducking during streams</div>
</div>
<button
onClick={() => setAutoGainControl(!autoGainControl)}
className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-status-online' : 'bg-surface-input'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${autoGainControl ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
</div>
</div>
{/* Connected Instances */}
<ConnectedInstances />
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
<button
onClick={handleLogout}
className="px-3 py-1 text-sm text-txt-danger hover:bg-accent-rose/10 rounded-full transition-colors"
>
Log Out
</button>
<div className="w-px h-5 bg-white/10" />
<button
onClick={handleSave}
disabled={isLoading}
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"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'account' && <AccountPanel />}
{tab === 'voice' && <VoicePanel />}
{tab === 'connections' && <ConnectionsPanel />}
{tab === 'instance' && isAdmin && <InstancePanel />}
</div>
</div>
</Modal>
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import { Toggle } from '../../ui/Toggle';
import type { InstanceAdminSettings } from '@backspace/shared';
export function GeneralPanel() {
@@ -70,19 +71,7 @@ export function GeneralPanel() {
<div className="text-sm font-medium text-txt-primary">Open Registration</div>
<div className="text-xs text-txt-tertiary mt-0.5">Allow new users to create accounts on this instance</div>
</div>
<button
type="button"
onClick={() => setDraft({ ...draft, registrationOpen: !draft.registrationOpen })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
draft.registrationOpen ? 'bg-accent-primary' : 'bg-interactive-muted'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
draft.registrationOpen ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<Toggle enabled={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} />
</label>
</div>
</div>
@@ -96,19 +85,7 @@ export function GeneralPanel() {
<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>
<button
type="button"
onClick={() => setDraft({ ...draft, discoveryEnabled: !draft.discoveryEnabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
draft.discoveryEnabled ? 'bg-accent-primary' : 'bg-interactive-muted'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
draft.discoveryEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<Toggle enabled={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} />
</label>
</div>
</div>
@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react';
import { useAuthStore } from '../../../stores/authStore';
import { Avatar } from '../../ui/Avatar';
import type { UserStatus } from '@backspace/shared';
export function AccountPanel() {
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [status, setStatus] = useState<UserStatus>(user?.status ?? 'online');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Reset form when user data changes (e.g. after external update)
useEffect(() => {
if (user) {
setDisplayName(user.displayName ?? '');
setCustomStatus(user.customStatus ?? '');
setStatus(user.status ?? 'online');
}
}, [user]);
if (!user) return null;
const hasChanges =
displayName !== (user.displayName ?? '') ||
customStatus !== (user.customStatus ?? '') ||
status !== (user.status ?? 'online');
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim(),
customStatus: customStatus.trim(),
status,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsLoading(false);
}
};
const handleReset = () => {
setDisplayName(user.displayName ?? '');
setCustomStatus(user.customStatus ?? '');
setStatus(user.status ?? 'online');
setError('');
};
return (
<div className="space-y-5">
{/* Profile preview */}
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg">
<Avatar
src={user.avatar}
name={user.displayName ?? user.username}
size={64}
status={user.status}
userId={user.homeUserId ?? user.id}
/>
<div>
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
<div className="text-txt-tertiary text-sm">@{user.username}</div>
{user.customStatus && (
<div className="text-txt-secondary text-sm mt-1">{user.customStatus}</div>
)}
</div>
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
{success && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
)}
{/* Profile section card */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Profile</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as UserStatus)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
>
<option value="online">Online</option>
<option value="idle">Idle</option>
<option value="dnd">Do Not Disturb</option>
</select>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
/>
</div>
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="What are you up to?"
/>
</div>
</div>
</div>
{hasChanges && (
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleReset}
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Reset
</button>
<button
onClick={handleSave}
disabled={isLoading}
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"
>
{isLoading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,5 @@
import { ConnectedInstances } from '../ConnectedInstances';
export function ConnectionsPanel() {
return <ConnectedInstances />;
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel';
import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel';
type SubTab = 'general' | 'streaming';
export function InstancePanel() {
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits);
const [subTab, setSubTab] = useState<SubTab>('general');
useEffect(() => {
fetchInstanceSettings();
fetchStreamingLimits();
}, [fetchInstanceSettings, fetchStreamingLimits]);
const pillClass = (t: SubTab) =>
`px-3 py-1 text-sm rounded-full transition-colors ${
subTab === t
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`;
return (
<div className="space-y-4">
{/* Sub-tab switcher */}
<div className="flex gap-1 p-1 bg-white/[0.02] rounded-full w-fit">
<button onClick={() => setSubTab('general')} className={pillClass('general')}>
General
</button>
<button onClick={() => setSubTab('streaming')} className={pillClass('streaming')}>
Streaming
</button>
</div>
{/* Content */}
{subTab === 'general' && <GeneralPanel />}
{subTab === 'streaming' && <StreamingPanel />}
</div>
);
}
@@ -0,0 +1,46 @@
import { useVoiceStore } from '../../../stores/voiceStore';
import { Toggle } from '../../ui/Toggle';
export function VoicePanel() {
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation);
const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl);
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
return (
<div className="space-y-5">
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Processing
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) filters keyboard, fans, and background noise</div>
</div>
<Toggle enabled={rnnoiseEnabled} onChange={setRnnoiseEnabled} />
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Echo Cancellation</div>
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
</div>
<Toggle enabled={echoCancellation} onChange={setEchoCancellation} />
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Auto Gain Control</div>
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume can cause voice ducking during streams</div>
</div>
<Toggle enabled={autoGainControl} onChange={setAutoGainControl} />
</div>
</div>
</div>
</div>
);
}