From 4d9454382ca05566bfd7f756f0d55a76bcbcecb6 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:57:01 +0100 Subject: [PATCH] refactor: unify settings into tabbed modal with UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../web/src/components/layout/AppLayout.tsx | 2 - .../src/components/layout/ChannelSidebar.tsx | 31 +-- .../components/modals/InstanceSettings.tsx | 53 ---- .../src/components/modals/SpaceSettings.tsx | 4 +- .../src/components/modals/UserSettings.tsx | 234 +++++------------- .../instanceSettingsPanels/GeneralPanel.tsx | 29 +-- .../modals/settingsPanels/AccountPanel.tsx | 155 ++++++++++++ .../settingsPanels/ConnectionsPanel.tsx | 5 + .../modals/settingsPanels/InstancePanel.tsx | 43 ++++ .../modals/settingsPanels/VoicePanel.tsx | 46 ++++ packages/web/src/components/ui/Toggle.tsx | 22 ++ packages/web/src/stores/authStore.ts | 4 +- packages/web/src/stores/uiStore.ts | 1 - 13 files changed, 344 insertions(+), 285 deletions(-) delete mode 100644 packages/web/src/components/modals/InstanceSettings.tsx create mode 100644 packages/web/src/components/modals/settingsPanels/AccountPanel.tsx create mode 100644 packages/web/src/components/modals/settingsPanels/ConnectionsPanel.tsx create mode 100644 packages/web/src/components/modals/settingsPanels/InstancePanel.tsx create mode 100644 packages/web/src/components/modals/settingsPanels/VoicePanel.tsx create mode 100644 packages/web/src/components/ui/Toggle.tsx diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 7a637718..2588af55 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -12,7 +12,6 @@ import { CreateChannelModal } from '../modals/CreateChannel'; import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { SpaceSettingsModal } from '../modals/SpaceSettings'; -import { InstanceSettingsModal } from '../modals/InstanceSettings'; import { ChannelSettingsModal } from '../modals/ChannelSettingsModal'; import { NewDmModal } from '../modals/NewDmModal'; import { AddDmMemberModal } from '../modals/AddDmMemberModal'; @@ -274,7 +273,6 @@ export function AppLayout() { - diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 5933c686..786e5ecc 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -128,11 +128,9 @@ export function ChannelSidebar() { isDeafened={isDeafened} isServerMuted={isServerMuted} isServerDeafened={isServerDeafened} - isAdmin={!!user.isAdmin} onMicToggle={handleMicToggle} onDeafenToggle={handleDeafenToggle} - onSettingsClick={() => openModal('userSettings')} - onAdminClick={() => openModal('instanceSettings')} + onSettingsClick={(tab) => openModal('userSettings', tab ? { tab } : {})} /> @@ -459,22 +457,18 @@ function UserAreaPanel({ isDeafened, isServerMuted, isServerDeafened, - isAdmin, onMicToggle, onDeafenToggle, onSettingsClick, - onAdminClick, }: { user: any; isMuted: boolean; isDeafened: boolean; isServerMuted: boolean; isServerDeafened: boolean; - isAdmin: boolean; onMicToggle: () => void; onDeafenToggle: () => void; - onSettingsClick: () => void; - onAdminClick: () => void; + onSettingsClick: (tab?: string) => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [inputDevices, setInputDevices] = useState([]); @@ -684,7 +678,7 @@ function UserAreaPanel({ {/* Voice Settings link */} - {/* Admin — Instance Settings */} - {isAdmin && ( - - )} - {/* Settings */} - - - - - {/* Content */} -
- {tab === 'general' && } - {tab === 'streaming' && } -
- - - ); -} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index d0703a9c..b8227820 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -287,8 +287,8 @@ export function SpaceSettingsModal() { }`; return ( - -
+ +
{/* Tabs */}
diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index 6d22d711..8011f461 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -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('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 ( - -
- {/* Profile preview */} -
- -
-
{user.displayName ?? user.username}
-
@{user.username}
- {user.customStatus && ( -
{user.customStatus}
+ +
+ {/* Sidebar */} +
+
+ + + + {isAdmin && ( + )}
-
- - {error && ( -
{error}
- )} - {success && ( -
{success}
- )} - - {/* Profile section card */} -
-
Profile
-
-
- - -
- -
- - 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" - /> -
- -
- - 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?" - /> -
+
+
- {/* Voice Processing section card */} -
-
- Voice Processing -
-
-
-
-
AI Noise Suppression
-
ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise
-
- -
- -
-
-
Echo Cancellation
-
Removes echo when using speakers
-
- -
- -
-
-
Auto Gain Control
-
Auto-adjusts mic volume — can cause voice ducking during streams
-
- -
-
-
- - {/* Connected Instances */} - - -
-
-
- -
- -
-
+ {/* Content */} +
+ {tab === 'account' && } + {tab === 'voice' && } + {tab === 'connections' && } + {tab === 'instance' && isAdmin && }
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index 8a697ce5..a9ae3029 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -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() {
Open Registration
Allow new users to create accounts on this instance
- + setDraft({ ...draft, registrationOpen: v })} />
@@ -96,19 +85,7 @@ export function GeneralPanel() {
Space Discovery
Allow spaces to appear in the public Explore page
- + setDraft({ ...draft, discoveryEnabled: v })} />
diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx new file mode 100644 index 00000000..de9bdc02 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -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(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 ( +
+ {/* Profile preview */} +
+ +
+
{user.displayName ?? user.username}
+
@{user.username}
+ {user.customStatus && ( +
{user.customStatus}
+ )} +
+
+ + {error && ( +
{error}
+ )} + {success && ( +
{success}
+ )} + + {/* Profile section card */} +
+
Profile
+
+
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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?" + /> +
+
+
+ + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/ConnectionsPanel.tsx b/packages/web/src/components/modals/settingsPanels/ConnectionsPanel.tsx new file mode 100644 index 00000000..ba0d1d7f --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/ConnectionsPanel.tsx @@ -0,0 +1,5 @@ +import { ConnectedInstances } from '../ConnectedInstances'; + +export function ConnectionsPanel() { + return ; +} diff --git a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx new file mode 100644 index 00000000..a3a5ece5 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx @@ -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('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 ( +
+ {/* Sub-tab switcher */} +
+ + +
+ + {/* Content */} + {subTab === 'general' && } + {subTab === 'streaming' && } +
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx new file mode 100644 index 00000000..61f12b9c --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx @@ -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 ( +
+
+
+ Voice Processing +
+
+
+
+
AI Noise Suppression
+
ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise
+
+ +
+ +
+
+
Echo Cancellation
+
Removes echo when using speakers
+
+ +
+ +
+
+
Auto Gain Control
+
Auto-adjusts mic volume — can cause voice ducking during streams
+
+ +
+
+
+
+ ); +} diff --git a/packages/web/src/components/ui/Toggle.tsx b/packages/web/src/components/ui/Toggle.tsx new file mode 100644 index 00000000..db6a8f86 --- /dev/null +++ b/packages/web/src/components/ui/Toggle.tsx @@ -0,0 +1,22 @@ +interface ToggleProps { + enabled: boolean; + onChange: (enabled: boolean) => void; +} + +export function Toggle({ enabled, onChange }: ToggleProps) { + return ( + + ); +} diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index 8bade1b5..3eab5227 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { User } from '@backspace/shared'; +import type { User, UserStatus } from '@backspace/shared'; import { api } from '../api/client'; import { useChatStore } from './chatStore'; import { useSpaceStore } from './spaceStore'; @@ -16,7 +16,7 @@ interface AuthState { register: (username: string, password: string, displayName?: string) => Promise; logout: () => void; loadUser: () => Promise; - updateProfile: (data: { displayName?: string; avatar?: string; customStatus?: string }) => Promise; + updateProfile: (data: { displayName?: string; avatar?: string; customStatus?: string; status?: UserStatus }) => Promise; setUser: (user: User) => void; clearError: () => void; } diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 8e7397e2..51177d76 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -10,7 +10,6 @@ type ModalType = | 'userSettings' | 'spaceSettings' | 'channelSettings' - | 'instanceSettings' | 'imagePreview' | 'newDm' | 'addDmMember'