refactor: rename Server → Space across entire codebase

Renames all domain terminology from "Server" to "Space" throughout the
application — database schema, API routes, shared types, stores, components,
and UI strings. Files renamed: ServerSidebar → SpaceSidebar, CreateServer →
CreateSpace, JoinServer → JoinSpace, ServerSettings → SpaceSettings,
serverStore → spaceStore, routes/servers → routes/spaces.
This commit is contained in:
Jannis Braun
2026-03-08 20:08:24 +01:00
parent d18d9c51f7
commit fc06e25731
62 changed files with 1342 additions and 1342 deletions
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, getApiForOrigin } from '../../stores/serverStore';
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -15,8 +15,8 @@ export function AddDmMemberModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const dmChannels = useServerStore((s) => s.dmChannels);
const channelOriginMap = useServerStore((s) => s.channelOriginMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const channelOriginMap = useSpaceStore((s) => s.channelOriginMap);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions';
@@ -17,8 +17,8 @@ export function ChannelSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -31,7 +31,7 @@ export function ChannelSettingsModal() {
// Fetch overrides when modal opens
useEffect(() => {
if (!isOpen || !channelId || !currentServerId) {
if (!isOpen || !channelId || !currentSpaceId) {
setIsFetching(false);
return;
}
@@ -41,9 +41,9 @@ export function ChannelSettingsModal() {
api.channels.getOverrides(channelId)
.then((overrides: ChannelOverride[]) => {
// Check if @everyone role (id === serverId) has VIEW_CHANNEL denied
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
const everyoneOverride = overrides.find(
o => o.targetType === 'role' && o.targetId === currentServerId
o => o.targetType === 'role' && o.targetId === currentSpaceId
);
if (everyoneOverride) {
const denyBits = stringToPermissions(everyoneOverride.deny);
@@ -58,9 +58,9 @@ export function ChannelSettingsModal() {
.finally(() => {
setIsFetching(false);
});
}, [isOpen, channelId, currentServerId]);
}, [isOpen, channelId, currentSpaceId]);
if (!isOpen || !channel || !channelId || !currentServerId) return null;
if (!isOpen || !channel || !channelId || !currentSpaceId) return null;
const handleToggle = async () => {
setError('');
@@ -71,14 +71,14 @@ export function ChannelSettingsModal() {
// Make private: deny VIEW_CHANNEL for @everyone role
await api.channels.putOverride(channelId, {
targetType: 'role',
targetId: currentServerId,
targetId: currentSpaceId,
allow: '0',
deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
});
setIsPrivate(true);
} else {
// Make public: remove the @everyone VIEW_CHANNEL deny override
await api.channels.deleteOverride(channelId, 'role', currentServerId);
await api.channels.deleteOverride(channelId, 'role', currentSpaceId);
setIsPrivate(false);
}
} catch (err) {
@@ -144,7 +144,7 @@ export function ChannelSettingsModal() {
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<span>
This channel is hidden from members without explicit access. Users with the Administrator permission or server owners can always see all channels.
This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.
</span>
</div>
)}
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
@@ -11,8 +11,8 @@ export function CreateChannelModal() {
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const createChannel = useSpaceStore((s) => s.createChannel);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const isOpen = activeModal === 'createChannel';
@@ -25,14 +25,14 @@ export function CreateChannelModal() {
return;
}
if (!currentServerId) {
setError('No server selected');
if (!currentSpaceId) {
setError('No space selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
@@ -1,44 +1,44 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
export function CreateSpaceModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const createSpace = useSpaceStore((s) => s.createSpace);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const isOpen = activeModal === 'createSpace';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
setError('Space name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
const space = await createSpace(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
setError(err instanceof Error ? err.message : 'Failed to create space');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Space">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -47,14 +47,14 @@ export function CreateServerModal() {
)}
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="My Awesome Server"
placeholder="My Awesome Space"
autoFocus
/>
</div>
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InviteModal } from './InviteModal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
// Mock the stores by spying on their getState
beforeEach(() => {
@@ -12,9 +12,9 @@ beforeEach(() => {
activeModal: null,
modalData: {},
});
useServerStore.setState({
currentServerId: null,
servers: [],
useSpaceStore.setState({
currentSpaceId: null,
spaces: [],
});
});
@@ -28,8 +28,8 @@ describe('InviteModal', () => {
it('calls generateInvite and displays the invite URL when opened', async () => {
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -54,8 +54,8 @@ describe('InviteModal', () => {
it('displays an error when generateInvite fails', async () => {
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -69,8 +69,8 @@ describe('InviteModal', () => {
it('Copy button is disabled while loading', () => {
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -91,8 +91,8 @@ describe('InviteModal', () => {
});
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
@@ -10,20 +10,20 @@ export function InviteModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const currentServer = servers.find(s => s.id === currentServerId);
const instanceOrigin = currentServer?._instanceOrigin ?? '';
const generateInvite = useSpaceStore((s) => s.generateInvite);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = spaces.find(s => s.id === currentSpaceId);
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
useEffect(() => {
if (isOpen && currentServerId) {
if (isOpen && currentSpaceId) {
setIsLoading(true);
setError('');
generateInvite(currentServerId)
generateInvite(currentSpaceId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
@@ -33,7 +33,7 @@ export function InviteModal() {
setIsLoading(false);
});
}
}, [isOpen, currentServerId, generateInvite]);
}, [isOpen, currentSpaceId, generateInvite]);
const handleCopy = async () => {
if (!inviteUrl) return;
@@ -55,7 +55,7 @@ export function InviteModal() {
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-txt-secondary text-sm mb-4">
Share this invite link with friends to let them join your server.
Share this invite link with friends to let them join your space.
</p>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { JoinServerModal } from './JoinServer';
import { JoinSpaceModal } from './JoinSpace';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
@@ -18,41 +18,41 @@ vi.mock('react-router-dom', async () => {
beforeEach(() => {
mockNavigate.mockClear();
useUIStore.setState({ activeModal: null });
useServerStore.setState({
servers: [],
currentServerId: null,
useSpaceStore.setState({
spaces: [],
currentSpaceId: null,
});
});
function renderModal() {
return render(
<MemoryRouter>
<JoinServerModal />
<JoinSpaceModal />
</MemoryRouter>
);
}
describe('JoinServerModal', () => {
it('does not render when activeModal is not "joinServer"', () => {
describe('JoinSpaceModal', () => {
it('does not render when activeModal is not "joinSpace"', () => {
useUIStore.setState({ activeModal: null });
renderModal();
expect(screen.queryByText('Join a Server')).not.toBeInTheDocument();
expect(screen.queryByText('Join a Space')).not.toBeInTheDocument();
});
it('renders the form when opened', () => {
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
expect(screen.getByText('Join a Server')).toBeInTheDocument();
expect(screen.getByText('Join a Space')).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument();
expect(screen.getByText('Join Server')).toBeInTheDocument();
expect(screen.getByText('Join Space')).toBeInTheDocument();
});
it('shows validation error when submitting empty code', async () => {
const user = userEvent.setup();
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
expect(screen.getByText('Invite code is required')).toBeInTheDocument();
@@ -60,9 +60,9 @@ describe('JoinServerModal', () => {
it('calls joinByCode with the entered invite code and navigates on success', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' });
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-space-id', name: 'Test Space' });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
@@ -71,7 +71,7 @@ describe('JoinServerModal', () => {
await user.type(input, 'my-invite-code');
// Click join
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
// joinByCode should be called with the code
@@ -79,9 +79,9 @@ describe('JoinServerModal', () => {
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code');
});
// Should navigate to the new server
// Should navigate to the new space
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id');
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-space-id');
});
// Modal should close (activeModal becomes null)
@@ -91,15 +91,15 @@ describe('JoinServerModal', () => {
it('shows error message when joinByCode fails', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code'));
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
const input = screen.getByPlaceholderText('e.g. abc123');
await user.type(input, 'bad-code');
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
await waitFor(() => {
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, NotConnectedError } from '../../stores/serverStore';
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate, useParams } from 'react-router-dom';
@@ -9,7 +9,7 @@ import { parseInviteInput } from '../../utils/inviteParser';
type JoinPhase = 'input' | 'connect' | 'fallback';
export function JoinServerModal() {
export function JoinSpaceModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
@@ -23,13 +23,13 @@ export function JoinServerModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const joinByCode = useServerStore((s) => s.joinByCode);
const joinByCode = useSpaceStore((s) => s.joinByCode);
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const isOpen = activeModal === 'joinSpace';
// Pre-fill from URL param and reset state on open/close
useEffect(() => {
@@ -49,9 +49,9 @@ export function JoinServerModal() {
}, [isOpen, urlInviteCode]);
const joinAndNavigate = async (code: string, origin?: string) => {
const server = await joinByCode(code, origin || undefined);
const space = await joinByCode(code, origin || undefined);
closeModal();
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
};
// Phase 1: Submit invite code/URL
@@ -78,7 +78,7 @@ export function JoinServerModal() {
setPhase('connect');
setError('');
} else {
setError(err instanceof Error ? err.message : 'Failed to join server');
setError(err instanceof Error ? err.message : 'Failed to join space');
}
} finally {
setIsLoading(false);
@@ -128,7 +128,7 @@ export function JoinServerModal() {
} catch { /* ignore */ }
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Space">
{/* Error display (shared across all phases) */}
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -140,7 +140,7 @@ export function JoinServerModal() {
{phase === 'input' && (
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code or link to join a server.
Enter an invite code or link to join a space.
</p>
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
@@ -168,7 +168,7 @@ export function JoinServerModal() {
disabled={isLoading || !inviteCode.trim()}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
{isLoading ? 'Joining...' : 'Join Space'}
</button>
</div>
</form>
@@ -178,7 +178,7 @@ export function JoinServerModal() {
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this server.
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</p>
<div className="mb-4 space-y-2">
<div>
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -14,7 +14,7 @@ export function NewDmModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -59,7 +59,7 @@ export function NewDmModal() {
const handleSelectUser = async (user: User) => {
setError('');
try {
const existing = useServerStore.getState().findExistingDmForUser(user);
const existing = useSpaceStore.getState().findExistingDmForUser(user);
if (existing) {
closeModal();
useUIStore.getState().setShowDms(true);
@@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { InstanceStreamingLimits, ServerVisibility, JoinRequest } from '@backspace/shared';
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
const VALID_FRAMERATES = [30, 45, 60] as const;
@@ -215,40 +215,40 @@ function StreamingLimitsPanel() {
);
}
function DiscoveryPanel({ serverId }: { serverId: string }) {
const servers = useServerStore((s) => s.servers);
const updateServer = useServerStore((s) => s.updateServer);
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
const spaces = useSpaceStore((s) => s.spaces);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
const server = servers.find(s => s.id === serverId);
const space = spaces.find(s => s.id === spaceId);
const [visibility, setVisibility] = useState<ServerVisibility>(
(server?.visibility as ServerVisibility) ?? 'private'
const [visibility, setVisibility] = useState<SpaceVisibility>(
(space?.visibility as SpaceVisibility) ?? 'private'
);
const [description, setDescription] = useState(server?.description ?? '');
const [description, setDescription] = useState(space?.description ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (server) {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
if (space) {
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
}
}, [server]);
}, [space]);
if (!server) return null;
if (!space) return null;
const hasChanges =
visibility !== ((server.visibility as ServerVisibility) ?? 'private') ||
description !== (server.description ?? '');
visibility !== ((space.visibility as SpaceVisibility) ?? 'private') ||
description !== (space.description ?? '');
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await api.servers.update(serverId, { visibility, description: description.trim() });
await api.spaces.update(spaceId, { visibility, description: description.trim() });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -259,12 +259,12 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
};
const handleReset = () => {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
setSaveError('');
};
const visibilityOptions: { value: ServerVisibility; label: string; desc: string }[] = [
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
{ value: 'request', label: 'Request to Join', desc: 'Visible in Explore — people can request to join' },
{ value: 'public', label: 'Public', desc: 'Visible in Explore — anyone can join instantly' },
@@ -274,7 +274,7 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
<div className="space-y-4">
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Server discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
</div>
)}
@@ -348,14 +348,14 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (server.visibility as ServerVisibility) === 'request') && (
<JoinRequestsSection serverId={serverId} />
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
</div>
);
}
function JoinRequestsSection({ serverId }: { serverId: string }) {
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState('');
@@ -363,7 +363,7 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
useEffect(() => {
let cancelled = false;
setLoading(true);
api.explore.getJoinRequests(serverId, 'pending')
api.explore.getJoinRequests(spaceId, 'pending')
.then(({ requests: reqs }) => {
if (!cancelled) {
setRequests(reqs);
@@ -374,12 +374,12 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [serverId]);
}, [spaceId]);
const handleDecide = async (requestId: string, action: 'accept' | 'decline') => {
setActionError('');
try {
await api.explore.decideJoinRequest(serverId, requestId, action);
await api.explore.decideJoinRequest(spaceId, requestId, action);
setRequests(prev => prev.filter(r => r.id !== requestId));
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Action failed');
@@ -459,55 +459,55 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
);
}
export function ServerSettingsModal() {
export function SpaceSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const roles = useServerStore((s) => s.roles);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const members = useSpaceStore((s) => s.members);
const roles = useSpaceStore((s) => s.roles);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const deleteSpace = useSpaceStore((s) => s.deleteSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = useSettingsStore((s) => s.isAdmin);
const navigate = useNavigate();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'streaming'>('overview');
const [serverName, setServerName] = useState('');
const [spaceName, setSpaceName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
const serverPermissions = useServerStore((s) => s.serverPermissions);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
const canManageRoles = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_ROLES);
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
const isOwnerUser = space?.ownerId === currentUser?.id;
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
// Assignable roles: exclude @everyone (where role.id === serverId)
const assignableRoles = roles.filter(r => r.id !== currentServerId);
// Assignable roles: exclude @everyone (where role.id === spaceId)
const assignableRoles = roles.filter(r => r.id !== currentSpaceId);
React.useEffect(() => {
if (server) {
setServerName(server.name);
if (space) {
setSpaceName(space.name);
}
}, [server]);
}, [space]);
if (!server || !currentServerId) return null;
if (!space || !currentSpaceId) return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
await updateSpace(currentSpaceId, { name: spaceName.trim() });
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setError(err instanceof Error ? err.message : 'Failed to update space');
setIsLoading(false);
}
};
@@ -518,11 +518,11 @@ export function ServerSettingsModal() {
return;
}
try {
await deleteServer(currentServerId);
await deleteSpace(currentSpaceId);
closeModal();
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
setError(err instanceof Error ? err.message : 'Failed to delete space');
}
};
@@ -548,13 +548,13 @@ export function ServerSettingsModal() {
if (!roleIds) return;
try {
await api.servers.updateMember(currentServerId, userId, { roleIds: Array.from(roleIds) });
await api.spaces.updateMember(currentSpaceId, userId, { roleIds: Array.from(roleIds) });
setPendingRoleChanges(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
await loadServerDetail(currentServerId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update roles');
}
@@ -570,15 +570,15 @@ export function ServerSettingsModal() {
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
await api.spaces.removeMember(currentSpaceId, userId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Server Settings" maxWidth="max-w-xl">
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
@@ -590,7 +590,7 @@ export function ServerSettingsModal() {
>
Overview
</button>
{canManageServer && (
{canManageSpace && (
<button
onClick={() => setTab('discovery')}
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
@@ -630,18 +630,18 @@ export function ServerSettingsModal() {
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
value={spaceName}
onChange={(e) => setSpaceName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
disabled={!canManageServer}
disabled={!canManageSpace}
/>
</div>
{canManageServer && (
{canManageSpace && (
<>
<button
onClick={handleSave}
@@ -657,7 +657,7 @@ export function ServerSettingsModal() {
onClick={handleDelete}
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
>
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Server'}
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
</button>
</div>
</>
@@ -665,15 +665,15 @@ export function ServerSettingsModal() {
</div>
)}
{tab === 'discovery' && canManageServer && currentServerId && (
<DiscoveryPanel serverId={currentServerId} />
{tab === 'discovery' && canManageSpace && currentSpaceId && (
<DiscoveryPanel spaceId={currentSpaceId} />
)}
{tab === 'members' && (
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
const isOwner = member.userId === server.ownerId;
const isOwner = member.userId === space.ownerId;
const memberRoleIds = getMemberRoleIds(member);
const hasPendingChanges = pendingRoleChanges.has(member.userId);
@@ -696,7 +696,7 @@ export function ServerSettingsModal() {
Owner
</span>
)}
{member.roles?.filter(r => r.id !== currentServerId).map(r => (
{member.roles?.filter(r => r.id !== currentSpaceId).map(r => (
<span
key={r.id}
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
@@ -705,7 +705,7 @@ export function ServerSettingsModal() {
{r.name}
</span>
))}
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentServerId).length === 0) && (
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentSpaceId).length === 0) && (
<span className="text-[10px] text-txt-tertiary">No roles</span>
)}
</div>