feat: dedicated Instance Settings modal with admin controls
Move instance-level administration out of Space Settings into its own modal. Adds admin UI for instance name, registration toggle, and discovery toggle. Streaming limits panel relocated from SpaceSettings. - Add InstanceAdminSettings type and GET/PATCH /api/settings/instance - Add registration_open DB column (nullable, env var fallback) - Auth registration and instance info now check DB override - New InstanceSettings modal with General and Streaming tabs - Admin shield button in UserAreaPanel (visible to admins only) - Remove Streaming tab from SpaceSettings
This commit is contained in:
@@ -69,7 +69,8 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" },
|
||||
{ name: 'worker_id', type: 'INTEGER' },
|
||||
{ name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' }
|
||||
{ name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' },
|
||||
{ name: 'registration_open', type: 'INTEGER' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -192,6 +192,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'),
|
||||
maxResolution: integer('max_resolution').notNull().default(1080),
|
||||
maxFramerate: integer('max_framerate').notNull().default(60),
|
||||
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -71,12 +71,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (!config.registrationOpen) {
|
||||
const db = getDb();
|
||||
|
||||
// Check registration: DB setting overrides env var if explicitly set by admin
|
||||
const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined
|
||||
? instanceRow.registrationOpen === 1
|
||||
: config.registrationOpen;
|
||||
if (!registrationOpen) {
|
||||
return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: 'Username already taken', statusCode: 409 });
|
||||
|
||||
@@ -13,10 +13,15 @@ export async function instanceRoutes(app: FastifyInstance): Promise<void> {
|
||||
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
const instanceName = settings?.instanceName ?? 'Backspace';
|
||||
|
||||
// DB setting overrides env var if explicitly set by admin
|
||||
const registrationOpen = settings?.registrationOpen !== null && settings?.registrationOpen !== undefined
|
||||
? settings.registrationOpen === 1
|
||||
: config.registrationOpen;
|
||||
|
||||
const response: InstanceInfoResponse = {
|
||||
name: instanceName,
|
||||
version: BACKSPACE_VERSION,
|
||||
registrationOpen: config.registrationOpen,
|
||||
registrationOpen,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
import { config } from '../config.js';
|
||||
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080];
|
||||
const VALID_FRAMERATES = [30, 45, 60];
|
||||
@@ -126,4 +127,70 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send(rowToLimits(updatedRow));
|
||||
});
|
||||
|
||||
// GET /api/settings/instance — admin only, returns instance admin settings
|
||||
app.get('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can view instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!row) {
|
||||
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
|
||||
}
|
||||
|
||||
const response: InstanceAdminSettings = {
|
||||
instanceName: row.instanceName ?? 'Backspace',
|
||||
registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen,
|
||||
discoveryEnabled: row.discoveryEnabled === 1,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
|
||||
// PATCH /api/settings/instance — admin only, updates instance admin settings
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
if (body.instanceName !== undefined) {
|
||||
if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) {
|
||||
return reply.code(400).send({ error: 'Instance name must be 1-32 characters', statusCode: 400 });
|
||||
}
|
||||
updateData.instanceName = body.instanceName.trim();
|
||||
}
|
||||
|
||||
if (body.registrationOpen !== undefined) {
|
||||
updateData.registrationOpen = body.registrationOpen ? 1 : 0;
|
||||
}
|
||||
|
||||
if (body.discoveryEnabled !== undefined) {
|
||||
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
|
||||
}
|
||||
|
||||
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
|
||||
|
||||
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!updatedRow) {
|
||||
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
|
||||
}
|
||||
|
||||
const response: InstanceAdminSettings = {
|
||||
instanceName: updatedRow.instanceName ?? 'Backspace',
|
||||
registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen,
|
||||
discoveryEnabled: updatedRow.discoveryEnabled === 1,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -419,6 +419,12 @@ export interface UpdateFriendRequest {
|
||||
|
||||
// ─── Instance Settings Types ────────────────────────────────────────────────
|
||||
|
||||
export interface InstanceAdminSettings {
|
||||
instanceName: string;
|
||||
registrationOpen: boolean;
|
||||
discoveryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceStreamingLimits {
|
||||
maxBitrateKbps: number;
|
||||
minBitrateKbps: number;
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
Friend,
|
||||
FriendRequest,
|
||||
InstanceStreamingLimits,
|
||||
InstanceAdminSettings,
|
||||
InstanceInfoResponse,
|
||||
VerifyPasswordResponse,
|
||||
ExploreSpace,
|
||||
@@ -112,6 +113,8 @@ export class BackspaceApiClient {
|
||||
readonly settings: {
|
||||
getStreaming: () => Promise<InstanceStreamingLimits>;
|
||||
updateStreaming: (data: Partial<InstanceStreamingLimits>) => Promise<InstanceStreamingLimits>;
|
||||
getInstance: () => Promise<InstanceAdminSettings>;
|
||||
updateInstance: (data: Partial<InstanceAdminSettings>) => Promise<InstanceAdminSettings>;
|
||||
};
|
||||
|
||||
readonly instance: {
|
||||
@@ -300,6 +303,9 @@ export class BackspaceApiClient {
|
||||
getStreaming: () => request<InstanceStreamingLimits>('GET', '/settings/streaming'),
|
||||
updateStreaming: (data: Partial<InstanceStreamingLimits>) =>
|
||||
request<InstanceStreamingLimits>('PATCH', '/settings/streaming', data),
|
||||
getInstance: () => request<InstanceAdminSettings>('GET', '/settings/instance'),
|
||||
updateInstance: (data: Partial<InstanceAdminSettings>) =>
|
||||
request<InstanceAdminSettings>('PATCH', '/settings/instance', data),
|
||||
};
|
||||
|
||||
this.instance = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CreateChannelModal } from '../modals/CreateChannel';
|
||||
import { InviteModal } from '../modals/InviteModal';
|
||||
import { UserSettingsModal } from '../modals/UserSettings';
|
||||
import { SpaceSettingsModal } from '../modals/SpaceSettings';
|
||||
import { InstanceSettingsModal } from '../modals/InstanceSettings';
|
||||
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
|
||||
import { NewDmModal } from '../modals/NewDmModal';
|
||||
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
|
||||
@@ -273,6 +274,7 @@ export function AppLayout() {
|
||||
<InviteModal />
|
||||
<UserSettingsModal />
|
||||
<SpaceSettingsModal />
|
||||
<InstanceSettingsModal />
|
||||
<ChannelSettingsModal />
|
||||
<NewDmModal />
|
||||
<AddDmMemberModal />
|
||||
|
||||
@@ -122,9 +122,11 @@ export function ChannelSidebar() {
|
||||
user={user}
|
||||
isMuted={isMuted}
|
||||
isDeafened={isDeafened}
|
||||
isAdmin={!!user.isAdmin}
|
||||
onMicToggle={handleMicToggle}
|
||||
onDeafenToggle={handleDeafenToggle}
|
||||
onSettingsClick={() => openModal('userSettings')}
|
||||
onAdminClick={() => openModal('instanceSettings')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,16 +444,20 @@ function UserAreaPanel({
|
||||
user,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
isAdmin,
|
||||
onMicToggle,
|
||||
onDeafenToggle,
|
||||
onSettingsClick,
|
||||
onAdminClick,
|
||||
}: {
|
||||
user: any;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isAdmin: boolean;
|
||||
onMicToggle: () => void;
|
||||
onDeafenToggle: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onAdminClick: () => void;
|
||||
}) {
|
||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
@@ -812,6 +818,19 @@ function UserAreaPanel({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Admin — Instance Settings */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={onAdminClick}
|
||||
className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-accent-amber hover:bg-interactive-hover rounded-[4px] transition-colors"
|
||||
title="Instance Settings"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { GeneralPanel } from './instanceSettingsPanels/GeneralPanel';
|
||||
import { StreamingPanel } from './instanceSettingsPanels/StreamingPanel';
|
||||
|
||||
export function InstanceSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
|
||||
const fetchStreamingLimits = useSettingsStore((s) => s.fetchStreamingLimits);
|
||||
|
||||
const [tab, setTab] = useState<'general' | 'streaming'>('general');
|
||||
|
||||
const isOpen = activeModal === 'instanceSettings';
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchInstanceSettings();
|
||||
fetchStreamingLimits();
|
||||
}
|
||||
}, [isOpen, fetchInstanceSettings, fetchStreamingLimits]);
|
||||
|
||||
const tabClass = (t: typeof tab) =>
|
||||
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
|
||||
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Instance Settings" maxWidth="max-w-xl">
|
||||
<div className="flex gap-4 h-[min(460px,65vh)]">
|
||||
{/* Tabs */}
|
||||
<div className="w-32 flex-shrink-0 self-start z-10">
|
||||
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||
<button onClick={() => setTab('general')} className={tabClass('general')}>
|
||||
General
|
||||
</button>
|
||||
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||
Streaming
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||
{tab === 'general' && <GeneralPanel />}
|
||||
{tab === 'streaming' && <StreamingPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -9,230 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
||||
const VALID_FRAMERATES = [30, 45, 60] as const;
|
||||
|
||||
function formatKbps(kbps: number): string {
|
||||
return kbps >= 1000
|
||||
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
|
||||
: `${kbps} kbps`;
|
||||
}
|
||||
|
||||
function StreamingLimitsPanel() {
|
||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||
const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits);
|
||||
|
||||
const [draft, setDraft] = useState<InstanceStreamingLimits | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
}, [limits]);
|
||||
|
||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||
|
||||
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
await updateStreamingLimits(draft);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
const toggleResolution = (res: number) => {
|
||||
const current = new Set(draft.allowedResolutions);
|
||||
if (current.has(res)) {
|
||||
if (current.size <= 1) return; // Must have at least one
|
||||
current.delete(res);
|
||||
} else {
|
||||
current.add(res);
|
||||
}
|
||||
setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const toggleFramerate = (fps: number) => {
|
||||
const current = new Set(draft.allowedFramerates);
|
||||
if (current.has(fps)) {
|
||||
if (current.size <= 1) return;
|
||||
current.delete(fps);
|
||||
} else {
|
||||
current.add(fps);
|
||||
}
|
||||
setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none';
|
||||
const pillOn = 'bg-accent-primary text-white';
|
||||
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
These limits apply to all users on this instance. Users can pick values within these bounds.
|
||||
</div>
|
||||
|
||||
{/* Bandwidth */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Bitrate Range */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Bitrate Range
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={draft.maxBitrateKbps - 500}
|
||||
step={100}
|
||||
value={draft.minBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
||||
/>
|
||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
|
||||
<input
|
||||
type="range"
|
||||
min={draft.minBitrateKbps + 500}
|
||||
max={50000}
|
||||
step={500}
|
||||
value={draft.maxBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
||||
/>
|
||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bitrate Step */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Slider Step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={5000}
|
||||
step={50}
|
||||
value={draft.bitrateStepKbps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
|
||||
}}
|
||||
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
|
||||
/>
|
||||
<span className="text-[12px] text-txt-tertiary">kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Allowed Resolutions */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Allowed Resolutions
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_RESOLUTIONS.map((res) => (
|
||||
<button
|
||||
key={res}
|
||||
onClick={() => toggleResolution(res)}
|
||||
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
|
||||
>
|
||||
{res}p
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Frame Rates */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Allowed Frame Rates
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_FRAMERATES.map((fps) => (
|
||||
<button
|
||||
key={fps}
|
||||
onClick={() => toggleFramerate(fps)}
|
||||
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
|
||||
>
|
||||
{fps} fps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Reset */}
|
||||
{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>
|
||||
)}
|
||||
{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 pointer-events-auto">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||
|
||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
@@ -490,10 +267,9 @@ export function SpaceSettingsModal() {
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const isAdmin = useSettingsStore((s) => s.isAdmin);
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'streaming'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
const space = spaces.find(s => s.id === currentSpaceId);
|
||||
@@ -530,11 +306,6 @@ export function SpaceSettingsModal() {
|
||||
Roles
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||
Streaming
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -544,7 +315,6 @@ export function SpaceSettingsModal() {
|
||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'streaming' && isAdmin && <StreamingLimitsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSettingsStore } from '../../../stores/settingsStore';
|
||||
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 [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceSettings) setDraft({ ...instanceSettings });
|
||||
}, [instanceSettings]);
|
||||
|
||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||
|
||||
const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
await updateInstanceSettings(draft);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (instanceSettings) setDraft({ ...instanceSettings });
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Configure your Backspace instance. These settings affect all users.
|
||||
</div>
|
||||
|
||||
{/* Instance Name */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Instance Name</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">The name shown on the login page and to federated instances.</p>
|
||||
<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) })}
|
||||
placeholder="Backspace"
|
||||
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary placeholder:text-txt-tertiary"
|
||||
/>
|
||||
<div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Registration</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, registrationOpen: !draft.registrationOpen })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
draft.registrationOpen ? 'bg-accent-primary' : 'bg-interactive-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
draft.registrationOpen ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discovery */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Discovery</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, discoveryEnabled: !draft.discoveryEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
draft.discoveryEnabled ? 'bg-accent-primary' : 'bg-interactive-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
draft.discoveryEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</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="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 pointer-events-auto">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSettingsStore } from '../../../stores/settingsStore';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
||||
const VALID_FRAMERATES = [30, 45, 60] as const;
|
||||
|
||||
function formatKbps(kbps: number): string {
|
||||
return kbps >= 1000
|
||||
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
|
||||
: `${kbps} kbps`;
|
||||
}
|
||||
|
||||
export function StreamingPanel() {
|
||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||
const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits);
|
||||
|
||||
const [draft, setDraft] = useState<InstanceStreamingLimits | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
}, [limits]);
|
||||
|
||||
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
|
||||
|
||||
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
await updateStreamingLimits(draft);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
const toggleResolution = (res: number) => {
|
||||
const current = new Set(draft.allowedResolutions);
|
||||
if (current.has(res)) {
|
||||
if (current.size <= 1) return;
|
||||
current.delete(res);
|
||||
} else {
|
||||
current.add(res);
|
||||
}
|
||||
setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const toggleFramerate = (fps: number) => {
|
||||
const current = new Set(draft.allowedFramerates);
|
||||
if (current.has(fps)) {
|
||||
if (current.size <= 1) return;
|
||||
current.delete(fps);
|
||||
} else {
|
||||
current.add(fps);
|
||||
}
|
||||
setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none';
|
||||
const pillOn = 'bg-accent-primary text-white';
|
||||
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
These limits apply to all users on this instance. Users can pick values within these bounds.
|
||||
</div>
|
||||
|
||||
{/* Bandwidth */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Bitrate Range */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Bitrate Range
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={draft.maxBitrateKbps - 500}
|
||||
step={100}
|
||||
value={draft.minBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
||||
/>
|
||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
|
||||
<input
|
||||
type="range"
|
||||
min={draft.minBitrateKbps + 500}
|
||||
max={50000}
|
||||
step={500}
|
||||
value={draft.maxBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
||||
/>
|
||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bitrate Step */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Slider Step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={5000}
|
||||
step={50}
|
||||
value={draft.bitrateStepKbps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
|
||||
}}
|
||||
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
|
||||
/>
|
||||
<span className="text-[12px] text-txt-tertiary">kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Allowed Resolutions */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Allowed Resolutions
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_RESOLUTIONS.map((res) => (
|
||||
<button
|
||||
key={res}
|
||||
onClick={() => toggleResolution(res)}
|
||||
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
|
||||
>
|
||||
{res}p
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Frame Rates */}
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">
|
||||
Allowed Frame Rates
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_FRAMERATES.map((fps) => (
|
||||
<button
|
||||
key={fps}
|
||||
onClick={() => toggleFramerate(fps)}
|
||||
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
|
||||
>
|
||||
{fps} fps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Reset */}
|
||||
{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>
|
||||
)}
|
||||
{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 pointer-events-auto">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
import type { InstanceStreamingLimits } from '@backspace/shared';
|
||||
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface SettingsState {
|
||||
streamingLimits: InstanceStreamingLimits | null;
|
||||
instanceSettings: InstanceAdminSettings | null;
|
||||
isAdmin: boolean;
|
||||
fetchStreamingLimits: () => Promise<void>;
|
||||
updateStreamingLimits: (limits: Partial<InstanceStreamingLimits>) => Promise<void>;
|
||||
fetchInstanceSettings: () => Promise<void>;
|
||||
updateInstanceSettings: (data: Partial<InstanceAdminSettings>) => Promise<void>;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +30,7 @@ export function getStreamingLimits(): InstanceStreamingLimits {
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
streamingLimits: null,
|
||||
instanceSettings: null,
|
||||
isAdmin: false,
|
||||
|
||||
fetchStreamingLimits: async () => {
|
||||
@@ -44,5 +48,27 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
set({ streamingLimits: updated });
|
||||
},
|
||||
|
||||
fetchInstanceSettings: async () => {
|
||||
try {
|
||||
const settings = await api.settings.getInstance();
|
||||
set({ instanceSettings: settings });
|
||||
} catch (err) {
|
||||
console.warn('[Settings] Failed to fetch instance settings:', err);
|
||||
}
|
||||
},
|
||||
|
||||
updateInstanceSettings: async (data: Partial<InstanceAdminSettings>) => {
|
||||
const updated = await api.settings.updateInstance(data);
|
||||
set({ instanceSettings: updated });
|
||||
// If discoveryEnabled changed, also update it in streamingLimits for the DiscoveryPanel warning banner
|
||||
if (data.discoveryEnabled !== undefined) {
|
||||
set((state) => ({
|
||||
streamingLimits: state.streamingLimits
|
||||
? { ...state.streamingLimits, discoveryEnabled: updated.discoveryEnabled }
|
||||
: state.streamingLimits,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
setIsAdmin: (isAdmin: boolean) => set({ isAdmin }),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ type ModalType =
|
||||
| 'userSettings'
|
||||
| 'spaceSettings'
|
||||
| 'channelSettings'
|
||||
| 'instanceSettings'
|
||||
| 'imagePreview'
|
||||
| 'newDm'
|
||||
| 'addDmMember'
|
||||
|
||||
Reference in New Issue
Block a user