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, }; }