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