Files
backspace/packages/web/src/components/modals/SpaceSettings.tsx
T
Jannis Braun 4d9454382c refactor: unify settings into tabbed modal with UI polish
Split monolithic UserSettings into AccountPanel, VoicePanel, ConnectionsPanel,
and InstancePanel tabs. Remove standalone InstanceSettings modal. Add reusable
Toggle component fixing size deformation, color inconsistency (green→purple),
and flex-shrink issues. Fix custom status clearing by always sending the field
to the server. Wrap Log Out button in glass bubble. Add subtle card depth with
borders.
2026-03-10 03:57:01 +01:00

331 lines
14 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
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 space = spaces.find(s => s.id === spaceId);
const [visibility, setVisibility] = useState<SpaceVisibility>(
(space?.visibility as SpaceVisibility) ?? 'private'
);
const [description, setDescription] = useState(space?.description ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (space) {
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
}
}, [space]);
if (!space) return null;
const hasChanges =
visibility !== ((space.visibility as SpaceVisibility) ?? 'private') ||
description !== (space.description ?? '');
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await api.spaces.update(spaceId, { visibility, description: description.trim() });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleReset = () => {
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
setSaveError('');
};
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' },
];
return (
<div className="space-y-5">
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
</div>
)}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Visibility</div>
<p className="text-xs text-txt-tertiary mb-2">Control who can discover and join this space.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="space-y-1.5">
{visibilityOptions.map((opt) => (
<label
key={opt.value}
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
visibility === opt.value
? 'bg-interactive-selected'
: 'hover:bg-interactive-hover'
}`}
>
<input
type="radio"
name="visibility"
value={opt.value}
checked={visibility === opt.value}
onChange={() => setVisibility(opt.value)}
className="mt-0.5 accent-accent-primary"
/>
<div>
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
</div>
</label>
))}
</div>
</div>
</div>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Description</div>
<p className="text-xs text-txt-tertiary mb-2">A short summary shown on the Explore page.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for the Explore page..."
rows={3}
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 resize-none placeholder:text-txt-tertiary"
/>
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div>
</div>
{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>
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
{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>
);
}
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
api.explore.getJoinRequests(spaceId, 'pending')
.then(({ requests: reqs }) => {
if (!cancelled) {
setRequests(reqs);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [spaceId]);
const handleDecide = async (requestId: string, action: 'accept' | 'decline') => {
setActionError('');
try {
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');
}
};
return (
<div className="pt-4 border-t border-border-soft">
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
Pending Join Requests
</div>
{actionError && (
<div className="mb-2 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-xs">
{actionError}
</div>
)}
{loading ? (
<div className="text-sm text-txt-tertiary">Loading...</div>
) : requests.length === 0 ? (
<div className="text-sm text-txt-tertiary">No pending join requests</div>
) : (
<div className="space-y-2 max-h-[240px] overflow-y-auto scrollbar-thin">
{requests.map((req) => {
const user = req.user;
const displayName = user?.displayName ?? user?.username ?? 'Unknown';
return (
<div key={req.id} className="flex items-start gap-3 p-2.5 rounded bg-surface-base">
<Avatar
src={user?.avatar}
name={displayName}
size={32}
userId={user?.homeUserId ?? user?.id}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-txt-primary truncate">{displayName}</span>
{user?.username && (
<span className="text-xs text-txt-tertiary">@{user.username}</span>
)}
</div>
{req.message && (
<p className="text-xs text-txt-secondary mt-0.5 line-clamp-2">{req.message}</p>
)}
<span className="text-[10px] text-txt-tertiary">
{new Date(req.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleDecide(req.id, 'accept')}
className="p-1.5 rounded text-status-online hover:bg-status-online/20 transition-colors"
title="Accept"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</button>
<button
onClick={() => handleDecide(req.id, 'decline')}
className="p-1.5 rounded text-txt-danger hover:bg-accent-rose/20 transition-colors"
title="Decline"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
export function SpaceSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
const canBanMembers = hasPermissionBit(mySpacePerms, PermissionBits.BAN_MEMBERS);
if (!space || !currentSpaceId) return null;
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="Space Settings" maxWidth="max-w-2xl">
<div className="flex gap-4 h-[min(520px,70vh)]">
{/* 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('overview')} className={tabClass('overview')}>
Overview
</button>
{canManageSpace && (
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
Discovery
</button>
)}
<button onClick={() => setTab('members')} className={tabClass('members')}>
Members
</button>
{canManageRoles && (
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
Roles
</button>
)}
{canBanMembers && (
<button onClick={() => setTab('bans')} className={tabClass('bans')}>
Bans
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
</div>
</div>
</Modal>
);
}