feat: dedicated Instance Settings modal with admin controls

Move instance-level administration out of Space Settings into its own
modal. Adds admin UI for instance name, registration toggle, and
discovery toggle. Streaming limits panel relocated from SpaceSettings.

- Add InstanceAdminSettings type and GET/PATCH /api/settings/instance
- Add registration_open DB column (nullable, env var fallback)
- Auth registration and instance info now check DB override
- New InstanceSettings modal with General and Streaming tabs
- Admin shield button in UserAreaPanel (visible to admins only)
- Remove Streaming tab from SpaceSettings
This commit is contained in:
Jannis Braun
2026-03-09 03:52:01 +01:00
parent 096251e5b0
commit 450df432b2
15 changed files with 575 additions and 239 deletions
@@ -0,0 +1,53 @@
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>
);
}
@@ -9,230 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
const VALID_FRAMERATES = [30, 45, 60] as const;
function formatKbps(kbps: number): string {
return kbps >= 1000
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
: `${kbps} kbps`;
}
function StreamingLimitsPanel() {
const limits = useSettingsStore((s) => s.streamingLimits);
const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits);
const [draft, setDraft] = useState<InstanceStreamingLimits | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (limits) setDraft({ ...limits });
}, [limits]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits);
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await updateStreamingLimits(draft);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (limits) setDraft({ ...limits });
setSaveError('');
};
const toggleResolution = (res: number) => {
const current = new Set(draft.allowedResolutions);
if (current.has(res)) {
if (current.size <= 1) return; // Must have at least one
current.delete(res);
} else {
current.add(res);
}
setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) });
};
const toggleFramerate = (fps: number) => {
const current = new Set(draft.allowedFramerates);
if (current.has(fps)) {
if (current.size <= 1) return;
current.delete(fps);
} else {
current.add(fps);
}
setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) });
};
const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none';
const pillOn = 'bg-accent-primary text-white';
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
These limits apply to all users on this instance. Users can pick values within these bounds.
</div>
{/* Bandwidth */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Bitrate Range */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Bitrate Range
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
<input
type="range"
min={100}
max={draft.maxBitrateKbps - 500}
step={100}
value={draft.minBitrateKbps}
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
</div>
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
<input
type="range"
min={draft.minBitrateKbps + 500}
max={50000}
step={500}
value={draft.maxBitrateKbps}
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
</div>
</div>
</div>
{/* Bitrate Step */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Slider Step
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={50}
max={5000}
step={50}
value={draft.bitrateStepKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
}}
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
/>
<span className="text-[12px] text-txt-tertiary">kbps</span>
</div>
</div>
</div>
</div>
{/* Quality */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Allowed Resolutions */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Resolutions
</div>
<div className="flex gap-1.5">
{VALID_RESOLUTIONS.map((res) => (
<button
key={res}
onClick={() => toggleResolution(res)}
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
>
{res}p
</button>
))}
</div>
</div>
{/* Allowed Frame Rates */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Frame Rates
</div>
<div className="flex gap-1.5">
{VALID_FRAMERATES.map((fps) => (
<button
key={fps}
onClick={() => toggleFramerate(fps)}
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
>
{fps} fps
</button>
))}
</div>
</div>
</div>
</div>
{/* Save / Reset */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
)}
{saveSuccess && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</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={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
const spaces = useSpaceStore((s) => s.spaces);
@@ -490,10 +267,9 @@ export function SpaceSettingsModal() {
const closeModal = useUIStore((s) => s.closeModal);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const isAdmin = useSettingsStore((s) => s.isAdmin);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'streaming'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview');
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
@@ -530,11 +306,6 @@ export function SpaceSettingsModal() {
Roles
</button>
)}
{isAdmin && (
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
Streaming
</button>
)}
</div>
</div>
@@ -544,7 +315,6 @@ export function SpaceSettingsModal() {
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'streaming' && isAdmin && <StreamingLimitsPanel />}
</div>
</div>
</Modal>
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import type { InstanceAdminSettings } from '@backspace/shared';
export function GeneralPanel() {
const instanceSettings = useSettingsStore((s) => s.instanceSettings);
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
const [draft, setDraft] = useState<InstanceAdminSettings | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (instanceSettings) setDraft({ ...instanceSettings });
}, [instanceSettings]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings);
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await updateInstanceSettings(draft);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (instanceSettings) setDraft({ ...instanceSettings });
setSaveError('');
};
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Configure your Backspace instance. These settings affect all users.
</div>
{/* Instance Name */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Instance Name</div>
<p className="text-xs text-txt-tertiary mb-2">The name shown on the login page and to federated instances.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<input
type="text"
value={draft.instanceName}
onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })}
placeholder="Backspace"
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary placeholder:text-txt-tertiary"
/>
<div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div>
</div>
</div>
{/* Registration */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Registration</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<label className="flex items-center justify-between cursor-pointer">
<div>
<div className="text-sm font-medium text-txt-primary">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>
</label>
</div>
</div>
{/* Discovery */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Discovery</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<label className="flex items-center justify-between cursor-pointer">
<div>
<div className="text-sm font-medium text-txt-primary">Space Discovery</div>
<div className="text-xs text-txt-tertiary mt-0.5">Allow spaces to appear in the public Explore page</div>
</div>
<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>
</label>
</div>
</div>
{/* Status messages */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
)}
{saveSuccess && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)}
{/* Save / Reset bar */}
{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={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,226 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import type { InstanceStreamingLimits } from '@backspace/shared';
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
const VALID_FRAMERATES = [30, 45, 60] as const;
function formatKbps(kbps: number): string {
return kbps >= 1000
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
: `${kbps} kbps`;
}
export function StreamingPanel() {
const limits = useSettingsStore((s) => s.streamingLimits);
const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits);
const [draft, setDraft] = useState<InstanceStreamingLimits | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (limits) setDraft({ ...limits });
}, [limits]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits);
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await updateStreamingLimits(draft);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (limits) setDraft({ ...limits });
setSaveError('');
};
const toggleResolution = (res: number) => {
const current = new Set(draft.allowedResolutions);
if (current.has(res)) {
if (current.size <= 1) return;
current.delete(res);
} else {
current.add(res);
}
setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) });
};
const toggleFramerate = (fps: number) => {
const current = new Set(draft.allowedFramerates);
if (current.has(fps)) {
if (current.size <= 1) return;
current.delete(fps);
} else {
current.add(fps);
}
setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) });
};
const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none';
const pillOn = 'bg-accent-primary text-white';
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
These limits apply to all users on this instance. Users can pick values within these bounds.
</div>
{/* Bandwidth */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Bitrate Range */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Bitrate Range
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
<input
type="range"
min={100}
max={draft.maxBitrateKbps - 500}
step={100}
value={draft.minBitrateKbps}
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
</div>
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
<input
type="range"
min={draft.minBitrateKbps + 500}
max={50000}
step={500}
value={draft.maxBitrateKbps}
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
</div>
</div>
</div>
{/* Bitrate Step */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Slider Step
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={50}
max={5000}
step={50}
value={draft.bitrateStepKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
}}
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
/>
<span className="text-[12px] text-txt-tertiary">kbps</span>
</div>
</div>
</div>
</div>
{/* Quality */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Allowed Resolutions */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Resolutions
</div>
<div className="flex gap-1.5">
{VALID_RESOLUTIONS.map((res) => (
<button
key={res}
onClick={() => toggleResolution(res)}
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
>
{res}p
</button>
))}
</div>
</div>
{/* Allowed Frame Rates */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Frame Rates
</div>
<div className="flex gap-1.5">
{VALID_FRAMERATES.map((fps) => (
<button
key={fps}
onClick={() => toggleFramerate(fps)}
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
>
{fps} fps
</button>
))}
</div>
</div>
</div>
</div>
{/* Save / Reset */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
)}
{saveSuccess && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</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={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}