From 437873c823ff3db55b5a7d66524166f1c7c7ab06 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:52:26 +0100 Subject: [PATCH] refactor: Instance settings back to separate pages with sidebar sub-links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../instanceSettingsPanels/GeneralPanel.tsx | 142 +++++++++++------- .../modals/settingsPanels/InstancePanel.tsx | 53 +++---- packages/web/src/hooks/useSettingsSections.ts | 55 +++++-- 3 files changed, 142 insertions(+), 108 deletions(-) diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index c9cf83d9..db61258a 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -1,76 +1,73 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; import { Toggle } from '../../ui/Toggle'; +import type { InstanceAdminSettings } from '@backspace/shared'; export function GeneralPanel() { const instanceSettings = useSettingsStore((s) => s.instanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); - const [instanceName, setInstanceName] = useState(''); - const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); - const [gifKeyDraft, setGifKeyDraft] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); const [gifKeyDirty, setGifKeyDirty] = useState(false); + const [gifKeyDraft, setGifKeyDraft] = useState(''); useEffect(() => { if (instanceSettings) { - setInstanceName(instanceSettings.instanceName); + setDraft({ ...instanceSettings }); setGifKeyDraft(''); setGifKeyDirty(false); } }, [instanceSettings]); - const autoSave = useCallback(async (payload: Record) => { - setSaveStatus('saving'); + if (!draft) return
Loading settings...
; + + 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(''); + setSaveSuccess(false); try { + const payload: Partial = { + instanceName: draft!.instanceName, + registrationOpen: draft!.registrationOpen, + discoveryEnabled: draft!.discoveryEnabled, + }; + if (gifKeyDirty) { + payload.gifApiKey = gifKeyDraft; + } await updateInstanceSettings(payload); - setSaveStatus('saved'); - setTimeout(() => setSaveStatus('idle'), 1500); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save'); - setSaveStatus('error'); - setTimeout(() => { setSaveStatus('idle'); setSaveError(''); }, 3000); - } - }, [updateInstanceSettings]); - - if (!instanceSettings) return
Loading settings...
; - - 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) { - autoSave({ gifApiKey: gifKeyDraft }); setGifKeyDirty(false); setGifKeyDraft(''); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); } }; - const handleClearGifKey = () => { - autoSave({ gifApiKey: '' }); + const handleReset = () => { + if (instanceSettings) setDraft({ ...instanceSettings }); + setGifKeyDirty(false); + setGifKeyDraft(''); + setSaveError(''); }; return ( -
- {/* Save status indicator */} - {saveStatus === 'saving' && ( -
Saving...
- )} - {saveStatus === 'saved' && ( -
Saved
- )} - {saveStatus === 'error' && ( -
{saveError}
- )} +
e.preventDefault()}> +
+ Configure your Backspace instance. These settings affect all users. +
{/* Instance Name */}
@@ -79,13 +76,12 @@ export function GeneralPanel() {
setInstanceName(e.target.value.slice(0, 32))} - onBlur={handleInstanceNameBlur} + value={draft.instanceName} + onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })} placeholder="Backspace" className="input-standard w-full" /> -
{instanceName.length}/32
+
{draft.instanceName.length}/32
@@ -98,7 +94,7 @@ export function GeneralPanel() {
Open Registration
Allow new users to create accounts on this instance
- handleToggle('registrationOpen', v)} /> + setDraft({ ...draft, registrationOpen: v })} /> @@ -112,7 +108,7 @@ export function GeneralPanel() {
Space Discovery
Allow spaces to appear in the public Explore page
- handleToggle('discoveryEnabled', v)} /> + setDraft({ ...draft, discoveryEnabled: v })} /> @@ -128,20 +124,19 @@ export function GeneralPanel() { type="password" value={gifKeyDirty ? gifKeyDraft : ''} onChange={(e) => { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }} - onBlur={handleGifKeyBlur} - placeholder={instanceSettings.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'} + placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'} className="input-standard w-full" autoComplete="off" />
- {instanceSettings.gifEnabled ? 'Enabled' : 'Not configured'} + {draft.gifEnabled ? 'Enabled' : 'Not configured'} - {instanceSettings.gifEnabled && !gifKeyDirty && ( + {draft.gifEnabled && !gifKeyDirty && (
- + + {/* Status messages */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Settings saved
+ )} + + {/* Save / Reset bar */} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} + ); } diff --git a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx index 62c8882f..9de17238 100644 --- a/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/InstancePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; import { useSettingsSections } from '../../../hooks/useSettingsSections'; import type { SettingsSection } from '../SettingsSectionsContext'; @@ -7,6 +7,8 @@ import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel'; import { StoragePanel } from '../instanceSettingsPanels/StoragePanel'; import { UsersPanel } from '../instanceSettingsPanels/UsersPanel'; +type SubTab = 'general' | 'streaming' | 'storage' | 'users'; + const SECTIONS: SettingsSection[] = [ { id: 'general', label: 'General' }, { id: 'streaming', label: 'Streaming' }, @@ -17,7 +19,15 @@ const SECTIONS: SettingsSection[] = [ export function InstancePanel() { const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings); const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits); - const { sectionRef } = useSettingsSections(SECTIONS); + + const [subTab, setSubTab] = useState('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(() => { fetchInstanceSettings(); @@ -25,40 +35,11 @@ export function InstancePanel() { }, [fetchInstanceSettings, fetchStreamingLimits]); return ( -
- {/* General */} -

- General -

-

Configure your Backspace instance. These settings affect all users.

- - -
- - {/* Streaming */} -

- Streaming -

-

These limits apply to all users on this instance. Users can pick values within these bounds.

- - -
- - {/* Storage */} -

- Storage -

-

Monitor file storage usage and clean up orphaned files.

- - -
- - {/* Users */} -

- Users -

-

View and manage user accounts on this instance.

- +
+ {subTab === 'general' && } + {subTab === 'streaming' && } + {subTab === 'storage' && } + {subTab === 'users' && }
); } diff --git a/packages/web/src/hooks/useSettingsSections.ts b/packages/web/src/hooks/useSettingsSections.ts index a8b46543..29ccd49a 100644 --- a/packages/web/src/hooks/useSettingsSections.ts +++ b/packages/web/src/hooks/useSettingsSections.ts @@ -1,15 +1,27 @@ import { useRef, useCallback, useLayoutEffect } from 'react'; 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(); - // 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); ctxRef.current = ctx; + const onNavigateRef = useRef(options?.onNavigate); + onNavigateRef.current = options?.onNavigate; + + const isTabMode = !!options?.onNavigate; + const scrollContainerRef = ctx?.scrollContainerRef ?? null; const sectionElementsRef = useRef(new Map()); const sectionRefCallbacksRef = useRef(new Map void>()); @@ -25,8 +37,22 @@ export function useSettingsSections(sections: SettingsSection[]) { }; }, [sections]); - // scrollToSection implementation - const scrollToSection = useCallback((id: string) => { + // In tab mode, sync activeTab to context + 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); if (!el) return; @@ -52,13 +78,15 @@ export function useSettingsSections(sections: SettingsSection[]) { } }, [scrollContainerRef]); - // Register scrollToSection into context + // Register navigateToSection into context useLayoutEffect(() => { - ctxRef.current?.setScrollToSection(scrollToSection); - }, [scrollToSection]); + ctxRef.current?.setScrollToSection(navigateToSection); + }, [navigateToSection]); - // Set up IntersectionObserver + // Set up IntersectionObserver (scroll mode only) useLayoutEffect(() => { + if (isTabMode) return; // No scroll-spy in tab mode + const container = scrollContainerRef?.current; if (!container || sections.length === 0) return; @@ -80,7 +108,6 @@ export function useSettingsSections(sections: SettingsSection[]) { } ); - // Observe all registered section elements sectionElementsRef.current.forEach((el) => { observerRef.current?.observe(el); }); @@ -89,7 +116,7 @@ export function useSettingsSections(sections: SettingsSection[]) { observerRef.current?.disconnect(); observerRef.current = null; }; - }, [sections, scrollContainerRef]); + }, [sections, scrollContainerRef, isTabMode]); // Stable callback ref factory (cached per id to avoid re-attach) const sectionRef = useCallback((id: string) => { @@ -113,6 +140,6 @@ export function useSettingsSections(sections: SettingsSection[]) { return { sectionRef, - scrollToSection, + scrollToSection: navigateToSection, }; }