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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user