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