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
@@ -12,7 +12,6 @@ import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal'; import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings'; import { UserSettingsModal } from '../modals/UserSettings';
import { SpaceSettingsModal } from '../modals/SpaceSettings'; import { SpaceSettingsModal } from '../modals/SpaceSettings';
import { InstanceSettingsModal } from '../modals/InstanceSettings';
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal'; import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
import { NewDmModal } from '../modals/NewDmModal'; import { NewDmModal } from '../modals/NewDmModal';
import { AddDmMemberModal } from '../modals/AddDmMemberModal'; import { AddDmMemberModal } from '../modals/AddDmMemberModal';
@@ -274,7 +273,6 @@ export function AppLayout() {
<InviteModal /> <InviteModal />
<UserSettingsModal /> <UserSettingsModal />
<SpaceSettingsModal /> <SpaceSettingsModal />
<InstanceSettingsModal />
<ChannelSettingsModal /> <ChannelSettingsModal />
<NewDmModal /> <NewDmModal />
<AddDmMemberModal /> <AddDmMemberModal />
@@ -128,11 +128,9 @@ export function ChannelSidebar() {
isDeafened={isDeafened} isDeafened={isDeafened}
isServerMuted={isServerMuted} isServerMuted={isServerMuted}
isServerDeafened={isServerDeafened} isServerDeafened={isServerDeafened}
isAdmin={!!user.isAdmin}
onMicToggle={handleMicToggle} onMicToggle={handleMicToggle}
onDeafenToggle={handleDeafenToggle} onDeafenToggle={handleDeafenToggle}
onSettingsClick={() => openModal('userSettings')} onSettingsClick={(tab) => openModal('userSettings', tab ? { tab } : {})}
onAdminClick={() => openModal('instanceSettings')}
/> />
</div> </div>
</div> </div>
@@ -459,22 +457,18 @@ function UserAreaPanel({
isDeafened, isDeafened,
isServerMuted, isServerMuted,
isServerDeafened, isServerDeafened,
isAdmin,
onMicToggle, onMicToggle,
onDeafenToggle, onDeafenToggle,
onSettingsClick, onSettingsClick,
onAdminClick,
}: { }: {
user: any; user: any;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isServerMuted: boolean; isServerMuted: boolean;
isServerDeafened: boolean; isServerDeafened: boolean;
isAdmin: boolean;
onMicToggle: () => void; onMicToggle: () => void;
onDeafenToggle: () => void; onDeafenToggle: () => void;
onSettingsClick: () => void; onSettingsClick: (tab?: string) => void;
onAdminClick: () => void;
}) { }) {
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]); const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
@@ -684,7 +678,7 @@ function UserAreaPanel({
{/* Voice Settings link */} {/* Voice Settings link */}
<button <button
onClick={onSettingsClick} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
@@ -758,7 +752,7 @@ function UserAreaPanel({
{/* Voice Settings link */} {/* Voice Settings link */}
<button <button
onClick={onSettingsClick} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
@@ -837,24 +831,11 @@ function UserAreaPanel({
</svg> </svg>
</button> </button>
{/* Admin — Instance Settings */}
{isAdmin && (
<button
onClick={onAdminClick}
className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-accent-amber hover:bg-interactive-hover rounded-[4px] transition-colors"
title="Instance Settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
</svg>
</button>
)}
{/* Settings */} {/* Settings */}
<button <button
onClick={onSettingsClick} onClick={() => onSettingsClick()}
className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover rounded-[4px] transition-colors" className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover rounded-[4px] transition-colors"
title="User Settings" title="Settings"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
@@ -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 ( return (
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl"> <Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-2xl">
<div className="flex gap-4 h-[min(460px,65vh)]"> <div className="flex gap-4 h-[min(520px,70vh)]">
{/* Tabs */} {/* Tabs */}
<div className="w-32 flex-shrink-0 self-start z-10"> <div className="w-32 flex-shrink-0 self-start z-10">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5"> <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 { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useVoiceStore } from '../../stores/voiceStore'; import { AccountPanel } from './settingsPanels/AccountPanel';
import { Avatar } from '../ui/Avatar'; import { VoicePanel } from './settingsPanels/VoicePanel';
import { ConnectedInstances } from './ConnectedInstances'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
import { InstancePanel } from './settingsPanels/InstancePanel';
type SettingsTab = 'account' | 'voice' | 'connections' | 'instance';
export function UserSettingsModal() { export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal); const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal); const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user); const isAdmin = useAuthStore((s) => s.user?.isAdmin);
const updateProfile = useAuthStore((s) => s.updateProfile);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [tab, setTab] = useState<SettingsTab>('account');
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 isOpen = activeModal === 'userSettings'; const isOpen = activeModal === 'userSettings';
const handleSave = async () => { // Deep-linking: read modalData.tab when opening
setError(''); useEffect(() => {
setSuccess(''); if (isOpen) {
setIsLoading(true); const requested = modalData.tab as SettingsTab | undefined;
try { if (requested && ['account', 'voice', 'connections', 'instance'].includes(requested)) {
await updateProfile({ // Only allow instance tab for admins
displayName: displayName.trim() || undefined, if (requested === 'instance' && !isAdmin) {
customStatus: customStatus.trim() || undefined, setTab('account');
status: status as any, } else {
} as any); setTab(requested);
setSuccess('Profile updated!'); }
setTimeout(() => setSuccess(''), 2000); } else {
} catch (err) { setTab('account');
setError(err instanceof Error ? err.message : 'Failed to update profile'); }
} finally {
setIsLoading(false);
} }
}; }, [isOpen, modalData.tab, isAdmin]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
closeModal(); 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 ( return (
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg"> <Modal isOpen={isOpen} onClose={closeModal} title="Settings" maxWidth="max-w-2xl">
<div className="space-y-5"> <div className="flex gap-4 h-[min(520px,70vh)]">
{/* Profile preview */} {/* Sidebar */}
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg"> <div className="w-32 flex-shrink-0 flex flex-col gap-2">
<Avatar <div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
src={user.avatar} <button onClick={() => setTab('account')} className={tabClass('account')}>
name={user.displayName ?? user.username} Account
size={64} </button>
status={user.status} <button onClick={() => setTab('voice')} className={tabClass('voice')}>
userId={user.homeUserId ?? user.id} Voice
/> </button>
<div> <button onClick={() => setTab('connections')} className={tabClass('connections')}>
<div className="font-bold text-lg">{user.displayName ?? user.username}</div> Connections
<div className="text-txt-tertiary text-sm">@{user.username}</div> </button>
{user.customStatus && ( {isAdmin && (
<div className="text-txt-secondary text-sm mt-1">{user.customStatus}</div> <button onClick={() => setTab('instance')} className={tabClass('instance')}>
Instance
</button>
)} )}
</div> </div>
</div> <div className="glass-bubble rounded-lg p-1.5">
<button
{error && ( onClick={handleLogout}
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div> className="w-full px-2.5 py-1.5 rounded text-sm text-txt-danger hover:bg-accent-rose/10 transition-colors text-left"
)} >
{success && ( Log Out
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div> </button>
)}
{/* 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> </div>
</div> </div>
{/* Voice Processing section card */} {/* Content */}
<div> <div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5"> {tab === 'account' && <AccountPanel />}
Voice Processing {tab === 'voice' && <VoicePanel />}
</div> {tab === 'connections' && <ConnectionsPanel />}
<div className="rounded-lg bg-white/[0.02] p-3.5"> {tab === 'instance' && isAdmin && <InstancePanel />}
<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>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsStore } from '../../../stores/settingsStore';
import { Toggle } from '../../ui/Toggle';
import type { InstanceAdminSettings } from '@backspace/shared'; import type { InstanceAdminSettings } from '@backspace/shared';
export function GeneralPanel() { 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-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 className="text-xs text-txt-tertiary mt-0.5">Allow new users to create accounts on this instance</div>
</div> </div>
<button <Toggle enabled={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} />
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>
</label> </label>
</div> </div>
</div> </div>
@@ -96,19 +85,7 @@ export function GeneralPanel() {
<div className="text-sm font-medium text-txt-primary">Space Discovery</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-xs text-txt-tertiary mt-0.5">Allow spaces to appear in the public Explore page</div>
</div> </div>
<button <Toggle enabled={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} />
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>
</label> </label>
</div> </div>
</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>
);
}
+22
View File
@@ -0,0 +1,22 @@
interface ToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
}
export function Toggle({ enabled, onChange }: ToggleProps) {
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
enabled ? 'bg-accent-primary' : 'bg-interactive-muted'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { User } from '@backspace/shared'; import type { User, UserStatus } from '@backspace/shared';
import { api } from '../api/client'; import { api } from '../api/client';
import { useChatStore } from './chatStore'; import { useChatStore } from './chatStore';
import { useSpaceStore } from './spaceStore'; import { useSpaceStore } from './spaceStore';
@@ -16,7 +16,7 @@ interface AuthState {
register: (username: string, password: string, displayName?: string) => Promise<void>; register: (username: string, password: string, displayName?: string) => Promise<void>;
logout: () => void; logout: () => void;
loadUser: () => Promise<void>; loadUser: () => Promise<void>;
updateProfile: (data: { displayName?: string; avatar?: string; customStatus?: string }) => Promise<void>; updateProfile: (data: { displayName?: string; avatar?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
setUser: (user: User) => void; setUser: (user: User) => void;
clearError: () => void; clearError: () => void;
} }
-1
View File
@@ -10,7 +10,6 @@ type ModalType =
| 'userSettings' | 'userSettings'
| 'spaceSettings' | 'spaceSettings'
| 'channelSettings' | 'channelSettings'
| 'instanceSettings'
| 'imagePreview' | 'imagePreview'
| 'newDm' | 'newDm'
| 'addDmMember' | 'addDmMember'