fix: improve Instance settings section spacing and save UX

- Section headings: text-lg with descriptions, dividers spaced my-10
- GeneralPanel: convert to auto-save (toggles save immediately,
  text fields save on blur) — no more hidden save bar
- StreamingPanel: restore sticky save bar (complex multi-field form
  needs explicit save, and it's the only sticky bar now)
This commit is contained in:
Jannis Braun
2026-03-22 03:43:23 +01:00
parent fbf83cf8f4
commit ca9b971c29
3 changed files with 87 additions and 107 deletions
@@ -1,75 +1,76 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } 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 [draft, setDraft] = useState<InstanceAdminSettings | null>(null); const [instanceName, setInstanceName] = useState('');
const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [gifKeyDirty, setGifKeyDirty] = useState(false);
const [gifKeyDraft, setGifKeyDraft] = useState(''); const [gifKeyDraft, setGifKeyDraft] = useState('');
const [gifKeyDirty, setGifKeyDirty] = useState(false);
useEffect(() => { useEffect(() => {
if (instanceSettings) { if (instanceSettings) {
setDraft({ ...instanceSettings }); setInstanceName(instanceSettings.instanceName);
// Don't populate the input with the masked value — show empty field
setGifKeyDraft(''); setGifKeyDraft('');
setGifKeyDirty(false); setGifKeyDirty(false);
} }
}, [instanceSettings]); }, [instanceSettings]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>; const autoSave = useCallback(async (payload: Record<string, unknown>) => {
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 {
const payload: Partial<InstanceAdminSettings> = {
instanceName: draft!.instanceName,
registrationOpen: draft!.registrationOpen,
discoveryEnabled: draft!.discoveryEnabled,
};
// Only include gifApiKey when the user actually modified it
if (gifKeyDirty) {
payload.gifApiKey = gifKeyDraft;
}
await updateInstanceSettings(payload); await updateInstanceSettings(payload);
setGifKeyDirty(false); setSaveStatus('saved');
setGifKeyDraft(''); setTimeout(() => setSaveStatus('idle'), 1500);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) { } catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save'); setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally { setSaveStatus('error');
setSaving(false); 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 handleReset = () => { const handleGifKeyBlur = () => {
if (instanceSettings) setDraft({ ...instanceSettings }); if (gifKeyDirty) {
setGifKeyDirty(false); autoSave({ gifApiKey: gifKeyDraft });
setGifKeyDraft(''); setGifKeyDirty(false);
setSaveError(''); setGifKeyDraft('');
}
};
const handleClearGifKey = () => {
autoSave({ gifApiKey: '' });
}; };
return ( return (
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}> <div className="space-y-5">
<div className="text-xs text-txt-tertiary"> {/* Save status indicator */}
Configure your Backspace instance. These settings affect all users. {saveStatus === 'saving' && (
</div> <div className="text-xs text-txt-tertiary animate-pulse">Saving...</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>
@@ -78,12 +79,13 @@ 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={draft.instanceName} value={instanceName}
onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })} onChange={(e) => setInstanceName(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">{draft.instanceName.length}/32</div> <div className="text-[11px] text-txt-tertiary text-right mt-1">{instanceName.length}/32</div>
</div> </div>
</div> </div>
@@ -96,7 +98,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={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} /> <Toggle enabled={instanceSettings.registrationOpen} onChange={(v) => handleToggle('registrationOpen', v)} />
</label> </label>
</div> </div>
</div> </div>
@@ -110,7 +112,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={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} /> <Toggle enabled={instanceSettings.discoveryEnabled} onChange={(v) => handleToggle('discoveryEnabled', v)} />
</label> </label>
</div> </div>
</div> </div>
@@ -126,19 +128,20 @@ 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); }}
placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'} onBlur={handleGifKeyBlur}
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 ${
draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary' instanceSettings.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
}`}> }`}>
{draft.gifEnabled ? 'Enabled' : 'Not configured'} {instanceSettings.gifEnabled ? 'Enabled' : 'Not configured'}
</span> </span>
{draft.gifEnabled && !gifKeyDirty && ( {instanceSettings.gifEnabled && !gifKeyDirty && (
<button <button
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }} onClick={handleClearGifKey}
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
@@ -147,35 +150,6 @@ export function GeneralPanel() {
</div> </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="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">
<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>
)}
</form>
); );
} }
@@ -489,21 +489,23 @@ export function StreamingPanel() {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div> <div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)} )}
{hasChanges && ( {hasChanges && (
<div className="flex justify-center pt-3 pb-1"> <div className="sticky bottom-0 z-10 pointer-events-none">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up"> <div className="flex justify-center pt-3 pb-1">
<button <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
onClick={handleReset} <button
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors" onClick={handleReset}
> className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
Reset >
</button> Reset
<button </button>
onClick={handleSave} <button
disabled={saving} onClick={handleSave}
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" 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> {saving ? 'Saving...' : 'Save'}
</button>
</div>
</div> </div>
</div> </div>
)} )}
@@ -25,35 +25,39 @@ export function InstancePanel() {
}, [fetchInstanceSettings, fetchStreamingLimits]); }, [fetchInstanceSettings, fetchStreamingLimits]);
return ( return (
<div className="space-y-0"> <div>
{/* General */} {/* General */}
<h3 ref={sectionRef('general')} className="text-base font-semibold text-txt-primary mb-4"> <h3 ref={sectionRef('general')} className="text-lg font-semibold text-txt-primary mb-1">
General General
</h3> </h3>
<p className="text-sm text-txt-tertiary mb-5">Configure your Backspace instance. These settings affect all users.</p>
<GeneralPanel /> <GeneralPanel />
<div className="border-t border-white/[0.04] my-6" /> <div className="border-t border-white/[0.04] my-10" />
{/* Streaming */} {/* Streaming */}
<h3 ref={sectionRef('streaming')} className="text-base font-semibold text-txt-primary mb-4"> <h3 ref={sectionRef('streaming')} className="text-lg font-semibold text-txt-primary mb-1">
Streaming Streaming
</h3> </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 /> <StreamingPanel />
<div className="border-t border-white/[0.04] my-6" /> <div className="border-t border-white/[0.04] my-10" />
{/* Storage */} {/* Storage */}
<h3 ref={sectionRef('storage')} className="text-base font-semibold text-txt-primary mb-4"> <h3 ref={sectionRef('storage')} className="text-lg font-semibold text-txt-primary mb-1">
Storage Storage
</h3> </h3>
<p className="text-sm text-txt-tertiary mb-5">Monitor file storage usage and clean up orphaned files.</p>
<StoragePanel /> <StoragePanel />
<div className="border-t border-white/[0.04] my-6" /> <div className="border-t border-white/[0.04] my-10" />
{/* Users */} {/* Users */}
<h3 ref={sectionRef('users')} className="text-base font-semibold text-txt-primary mb-4"> <h3 ref={sectionRef('users')} className="text-lg font-semibold text-txt-primary mb-1">
Users Users
</h3> </h3>
<p className="text-sm text-txt-tertiary mb-5">View and manage user accounts on this instance.</p>
<UsersPanel /> <UsersPanel />
</div> </div>
); );