refactor: Instance settings back to separate pages with sidebar sub-links

The continuous scroll was wrong for Instance — its sub-panels are
fundamentally different page types (user table, storage tool, bitrate
matrix, toggles). Revert to conditional rendering.

The hook now supports two modes:
- Scroll mode (default): smooth scroll + IntersectionObserver spy
- Tab mode (onNavigate callback): sidebar sub-links switch tabs

Instance uses tab mode. The scroll-spy infrastructure remains for
future panels that benefit from it (e.g. Account sections).

Also reverts GeneralPanel to sticky save bar since it's on its own
page again with no overlap risk.
This commit is contained in:
Jannis Braun
2026-03-22 03:52:26 +01:00
parent ca9b971c29
commit 437873c823
3 changed files with 142 additions and 108 deletions
@@ -1,76 +1,73 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsStore } from '../../../stores/settingsStore';
import { Toggle } from '../../ui/Toggle'; import { Toggle } from '../../ui/Toggle';
import type { InstanceAdminSettings } from '@backspace/shared';
export function GeneralPanel() { export function GeneralPanel() {
const instanceSettings = useSettingsStore((s) => s.instanceSettings); const instanceSettings = useSettingsStore((s) => s.instanceSettings);
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
const [instanceName, setInstanceName] = useState(''); const [draft, setDraft] = useState<InstanceAdminSettings | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
const [gifKeyDraft, setGifKeyDraft] = useState(''); const [saveSuccess, setSaveSuccess] = useState(false);
const [gifKeyDirty, setGifKeyDirty] = useState(false); const [gifKeyDirty, setGifKeyDirty] = useState(false);
const [gifKeyDraft, setGifKeyDraft] = useState('');
useEffect(() => { useEffect(() => {
if (instanceSettings) { if (instanceSettings) {
setInstanceName(instanceSettings.instanceName); setDraft({ ...instanceSettings });
setGifKeyDraft(''); setGifKeyDraft('');
setGifKeyDirty(false); setGifKeyDirty(false);
} }
}, [instanceSettings]); }, [instanceSettings]);
const autoSave = useCallback(async (payload: Record<string, unknown>) => { if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
setSaveStatus('saving');
const baseChanges = instanceSettings && draft
? draft.instanceName !== instanceSettings.instanceName ||
draft.registrationOpen !== instanceSettings.registrationOpen ||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled
: false;
const hasChanges = baseChanges || gifKeyDirty;
const handleSave = async () => {
setSaving(true);
setSaveError(''); setSaveError('');
setSaveSuccess(false);
try { try {
await updateInstanceSettings(payload); const payload: Partial<InstanceAdminSettings> = {
setSaveStatus('saved'); instanceName: draft!.instanceName,
setTimeout(() => setSaveStatus('idle'), 1500); registrationOpen: draft!.registrationOpen,
} catch (err) { discoveryEnabled: draft!.discoveryEnabled,
setSaveError(err instanceof Error ? err.message : 'Failed to save');
setSaveStatus('error');
setTimeout(() => { setSaveStatus('idle'); setSaveError(''); }, 3000);
}
}, [updateInstanceSettings]);
if (!instanceSettings) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const handleToggle = (key: string, value: boolean) => {
autoSave({ [key]: value });
}; };
const handleInstanceNameBlur = () => {
const trimmed = instanceName.trim();
if (trimmed && trimmed !== instanceSettings.instanceName) {
autoSave({ instanceName: trimmed });
}
};
const handleGifKeyBlur = () => {
if (gifKeyDirty) { if (gifKeyDirty) {
autoSave({ gifApiKey: gifKeyDraft }); payload.gifApiKey = gifKeyDraft;
}
await updateInstanceSettings(payload);
setGifKeyDirty(false); setGifKeyDirty(false);
setGifKeyDraft(''); setGifKeyDraft('');
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
} }
}; };
const handleClearGifKey = () => { const handleReset = () => {
autoSave({ gifApiKey: '' }); if (instanceSettings) setDraft({ ...instanceSettings });
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveError('');
}; };
return ( return (
<div className="space-y-5"> <form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
{/* Save status indicator */} <div className="text-xs text-txt-tertiary">
{saveStatus === 'saving' && ( Configure your Backspace instance. These settings affect all users.
<div className="text-xs text-txt-tertiary animate-pulse">Saving...</div> </div>
)}
{saveStatus === 'saved' && (
<div className="text-xs text-status-online">Saved</div>
)}
{saveStatus === 'error' && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
)}
{/* Instance Name */} {/* Instance Name */}
<div> <div>
@@ -79,13 +76,12 @@ export function GeneralPanel() {
<div className="rounded-lg bg-white/[0.02] p-3.5"> <div className="rounded-lg bg-white/[0.02] p-3.5">
<input <input
type="text" type="text"
value={instanceName} value={draft.instanceName}
onChange={(e) => setInstanceName(e.target.value.slice(0, 32))} onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })}
onBlur={handleInstanceNameBlur}
placeholder="Backspace" placeholder="Backspace"
className="input-standard w-full" className="input-standard w-full"
/> />
<div className="text-[11px] text-txt-tertiary text-right mt-1">{instanceName.length}/32</div> <div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div>
</div> </div>
</div> </div>
@@ -98,7 +94,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>
<Toggle enabled={instanceSettings.registrationOpen} onChange={(v) => handleToggle('registrationOpen', v)} /> <Toggle enabled={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} />
</label> </label>
</div> </div>
</div> </div>
@@ -112,7 +108,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>
<Toggle enabled={instanceSettings.discoveryEnabled} onChange={(v) => handleToggle('discoveryEnabled', v)} /> <Toggle enabled={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} />
</label> </label>
</div> </div>
</div> </div>
@@ -128,20 +124,19 @@ export function GeneralPanel() {
type="password" type="password"
value={gifKeyDirty ? gifKeyDraft : ''} value={gifKeyDirty ? gifKeyDraft : ''}
onChange={(e) => { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }} onChange={(e) => { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }}
onBlur={handleGifKeyBlur} placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'}
placeholder={instanceSettings.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'}
className="input-standard w-full" className="input-standard w-full"
autoComplete="off" autoComplete="off"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${ <span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${
instanceSettings.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary' draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
}`}> }`}>
{instanceSettings.gifEnabled ? 'Enabled' : 'Not configured'} {draft.gifEnabled ? 'Enabled' : 'Not configured'}
</span> </span>
{instanceSettings.gifEnabled && !gifKeyDirty && ( {draft.gifEnabled && !gifKeyDirty && (
<button <button
onClick={handleClearGifKey} onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors" className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
> >
Clear key Clear key
@@ -150,6 +145,37 @@ export function GeneralPanel() {
</div> </div>
</div> </div>
</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>
)}
</form>
); );
} }
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsStore } from '../../../stores/settingsStore';
import { useSettingsSections } from '../../../hooks/useSettingsSections'; import { useSettingsSections } from '../../../hooks/useSettingsSections';
import type { SettingsSection } from '../SettingsSectionsContext'; import type { SettingsSection } from '../SettingsSectionsContext';
@@ -7,6 +7,8 @@ import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel';
import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; import { StoragePanel } from '../instanceSettingsPanels/StoragePanel';
import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; import { UsersPanel } from '../instanceSettingsPanels/UsersPanel';
type SubTab = 'general' | 'streaming' | 'storage' | 'users';
const SECTIONS: SettingsSection[] = [ const SECTIONS: SettingsSection[] = [
{ id: 'general', label: 'General' }, { id: 'general', label: 'General' },
{ id: 'streaming', label: 'Streaming' }, { id: 'streaming', label: 'Streaming' },
@@ -17,7 +19,15 @@ const SECTIONS: SettingsSection[] = [
export function InstancePanel() { export function InstancePanel() {
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits); const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits);
const { sectionRef } = useSettingsSections(SECTIONS);
const [subTab, setSubTab] = useState<SubTab>('general');
const handleNavigate = useCallback((id: string) => {
setSubTab(id as SubTab);
}, []);
// Register sections for sidebar sub-links (tab mode — no scroll-spy)
useSettingsSections(SECTIONS, { onNavigate: handleNavigate, activeTab: subTab });
useEffect(() => { useEffect(() => {
fetchInstanceSettings(); fetchInstanceSettings();
@@ -25,40 +35,11 @@ export function InstancePanel() {
}, [fetchInstanceSettings, fetchStreamingLimits]); }, [fetchInstanceSettings, fetchStreamingLimits]);
return ( return (
<div> <div className="space-y-4">
{/* General */} {subTab === 'general' && <GeneralPanel />}
<h3 ref={sectionRef('general')} className="text-lg font-semibold text-txt-primary mb-1"> {subTab === 'streaming' && <StreamingPanel />}
General {subTab === 'storage' && <StoragePanel />}
</h3> {subTab === 'users' && <UsersPanel />}
<p className="text-sm text-txt-tertiary mb-5">Configure your Backspace instance. These settings affect all users.</p>
<GeneralPanel />
<div className="border-t border-white/[0.04] my-10" />
{/* Streaming */}
<h3 ref={sectionRef('streaming')} className="text-lg font-semibold text-txt-primary mb-1">
Streaming
</h3>
<p className="text-sm text-txt-tertiary mb-5">These limits apply to all users on this instance. Users can pick values within these bounds.</p>
<StreamingPanel />
<div className="border-t border-white/[0.04] my-10" />
{/* Storage */}
<h3 ref={sectionRef('storage')} className="text-lg font-semibold text-txt-primary mb-1">
Storage
</h3>
<p className="text-sm text-txt-tertiary mb-5">Monitor file storage usage and clean up orphaned files.</p>
<StoragePanel />
<div className="border-t border-white/[0.04] my-10" />
{/* Users */}
<h3 ref={sectionRef('users')} className="text-lg font-semibold text-txt-primary mb-1">
Users
</h3>
<p className="text-sm text-txt-tertiary mb-5">View and manage user accounts on this instance.</p>
<UsersPanel />
</div> </div>
); );
} }
+41 -14
View File
@@ -1,15 +1,27 @@
import { useRef, useCallback, useLayoutEffect } from 'react'; import { useRef, useCallback, useLayoutEffect } from 'react';
import { useSettingsSectionsContext, type SettingsSection } from '../components/modals/SettingsSectionsContext'; import { useSettingsSectionsContext, type SettingsSection } from '../components/modals/SettingsSectionsContext';
export function useSettingsSections(sections: SettingsSection[]) { interface UseSettingsSectionsOptions {
/**
* Tab mode: sidebar sub-links switch tabs instead of scrolling.
* When provided, clicking a sub-link calls onNavigate(id) instead of scrollIntoView.
* No IntersectionObserver is set up — the caller manages activeSection.
*/
onNavigate?: (id: string) => void;
/** In tab mode, the currently active tab id (drives sidebar highlight) */
activeTab?: string;
}
export function useSettingsSections(sections: SettingsSection[], options?: UseSettingsSectionsOptions) {
const ctx = useSettingsSectionsContext(); const ctx = useSettingsSectionsContext();
// Store ctx setters in refs to avoid depending on the ctx object in effects.
// The ctx object reference changes when any context value changes, which would
// cause infinite loops if used as an effect dependency (effect sets state →
// provider re-renders → new ctx object → effect re-runs).
const ctxRef = useRef(ctx); const ctxRef = useRef(ctx);
ctxRef.current = ctx; ctxRef.current = ctx;
const onNavigateRef = useRef(options?.onNavigate);
onNavigateRef.current = options?.onNavigate;
const isTabMode = !!options?.onNavigate;
const scrollContainerRef = ctx?.scrollContainerRef ?? null; const scrollContainerRef = ctx?.scrollContainerRef ?? null;
const sectionElementsRef = useRef(new Map<string, HTMLElement>()); const sectionElementsRef = useRef(new Map<string, HTMLElement>());
const sectionRefCallbacksRef = useRef(new Map<string, (el: HTMLElement | null) => void>()); const sectionRefCallbacksRef = useRef(new Map<string, (el: HTMLElement | null) => void>());
@@ -25,8 +37,22 @@ export function useSettingsSections(sections: SettingsSection[]) {
}; };
}, [sections]); }, [sections]);
// scrollToSection implementation // In tab mode, sync activeTab to context
const scrollToSection = useCallback((id: string) => { useLayoutEffect(() => {
if (isTabMode && options?.activeTab) {
ctxRef.current?.setActiveSection(options.activeTab);
}
}, [isTabMode, options?.activeTab]);
// navigateToSection: either calls onNavigate callback (tab mode) or scrollIntoView (scroll mode)
const navigateToSection = useCallback((id: string) => {
if (onNavigateRef.current) {
// Tab mode: delegate to caller
onNavigateRef.current(id);
return;
}
// Scroll mode: smooth scroll to element
const el = sectionElementsRef.current.get(id); const el = sectionElementsRef.current.get(id);
if (!el) return; if (!el) return;
@@ -52,13 +78,15 @@ export function useSettingsSections(sections: SettingsSection[]) {
} }
}, [scrollContainerRef]); }, [scrollContainerRef]);
// Register scrollToSection into context // Register navigateToSection into context
useLayoutEffect(() => { useLayoutEffect(() => {
ctxRef.current?.setScrollToSection(scrollToSection); ctxRef.current?.setScrollToSection(navigateToSection);
}, [scrollToSection]); }, [navigateToSection]);
// Set up IntersectionObserver // Set up IntersectionObserver (scroll mode only)
useLayoutEffect(() => { useLayoutEffect(() => {
if (isTabMode) return; // No scroll-spy in tab mode
const container = scrollContainerRef?.current; const container = scrollContainerRef?.current;
if (!container || sections.length === 0) return; if (!container || sections.length === 0) return;
@@ -80,7 +108,6 @@ export function useSettingsSections(sections: SettingsSection[]) {
} }
); );
// Observe all registered section elements
sectionElementsRef.current.forEach((el) => { sectionElementsRef.current.forEach((el) => {
observerRef.current?.observe(el); observerRef.current?.observe(el);
}); });
@@ -89,7 +116,7 @@ export function useSettingsSections(sections: SettingsSection[]) {
observerRef.current?.disconnect(); observerRef.current?.disconnect();
observerRef.current = null; observerRef.current = null;
}; };
}, [sections, scrollContainerRef]); }, [sections, scrollContainerRef, isTabMode]);
// Stable callback ref factory (cached per id to avoid re-attach) // Stable callback ref factory (cached per id to avoid re-attach)
const sectionRef = useCallback((id: string) => { const sectionRef = useCallback((id: string) => {
@@ -113,6 +140,6 @@ export function useSettingsSections(sections: SettingsSection[]) {
return { return {
sectionRef, sectionRef,
scrollToSection, scrollToSection: navigateToSection,
}; };
} }