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 { 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 [draft, setDraft] = useState<InstanceAdminSettings | null>(null);
const [saving, setSaving] = useState(false);
const [instanceName, setInstanceName] = useState('');
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [gifKeyDirty, setGifKeyDirty] = useState(false);
const [gifKeyDraft, setGifKeyDraft] = useState('');
const [gifKeyDirty, setGifKeyDirty] = useState(false);
useEffect(() => {
if (instanceSettings) {
setDraft({ ...instanceSettings });
// Don't populate the input with the masked value — show empty field
setInstanceName(instanceSettings.instanceName);
setGifKeyDraft('');
setGifKeyDirty(false);
}
}, [instanceSettings]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
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);
const autoSave = useCallback(async (payload: Record<string, unknown>) => {
setSaveStatus('saving');
setSaveError('');
setSaveSuccess(false);
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);
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 1500);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
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 handleReset = () => {
if (instanceSettings) setDraft({ ...instanceSettings });
const handleGifKeyBlur = () => {
if (gifKeyDirty) {
autoSave({ gifApiKey: gifKeyDraft });
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveError('');
}
};
const handleClearGifKey = () => {
autoSave({ gifApiKey: '' });
};
return (
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
<div className="text-xs text-txt-tertiary">
Configure your Backspace instance. These settings affect all users.
</div>
<div className="space-y-5">
{/* Save status indicator */}
{saveStatus === 'saving' && (
<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 */}
<div>
@@ -78,12 +79,13 @@ export function GeneralPanel() {
<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) })}
value={instanceName}
onChange={(e) => setInstanceName(e.target.value.slice(0, 32))}
onBlur={handleInstanceNameBlur}
placeholder="Backspace"
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>
@@ -96,7 +98,7 @@ export function GeneralPanel() {
<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>
<Toggle enabled={draft.registrationOpen} onChange={(v) => setDraft({ ...draft, registrationOpen: v })} />
<Toggle enabled={instanceSettings.registrationOpen} onChange={(v) => handleToggle('registrationOpen', v)} />
</label>
</div>
</div>
@@ -110,7 +112,7 @@ export function GeneralPanel() {
<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>
<Toggle enabled={draft.discoveryEnabled} onChange={(v) => setDraft({ ...draft, discoveryEnabled: v })} />
<Toggle enabled={instanceSettings.discoveryEnabled} onChange={(v) => handleToggle('discoveryEnabled', v)} />
</label>
</div>
</div>
@@ -126,19 +128,20 @@ export function GeneralPanel() {
type="password"
value={gifKeyDirty ? gifKeyDraft : ''}
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"
autoComplete="off"
/>
<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 ${
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>
{draft.gifEnabled && !gifKeyDirty && (
{instanceSettings.gifEnabled && !gifKeyDirty && (
<button
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
onClick={handleClearGifKey}
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
>
Clear key
@@ -147,35 +150,6 @@ export function GeneralPanel() {
</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,8 +489,9 @@ 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>
)}
{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">
<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"
@@ -506,6 +507,7 @@ export function StreamingPanel() {
</button>
</div>
</div>
</div>
)}
</div>
);
@@ -25,35 +25,39 @@ export function InstancePanel() {
}, [fetchInstanceSettings, fetchStreamingLimits]);
return (
<div className="space-y-0">
<div>
{/* 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
</h3>
<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-6" />
<div className="border-t border-white/[0.04] my-10" />
{/* 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
</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-6" />
<div className="border-t border-white/[0.04] my-10" />
{/* 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
</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-6" />
<div className="border-t border-white/[0.04] my-10" />
{/* 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
</h3>
<p className="text-sm text-txt-tertiary mb-5">View and manage user accounts on this instance.</p>
<UsersPanel />
</div>
);