feat: refactor space settings into panel components with roles management
Restructure SpaceSettings modal from 778-line monolith into thin orchestrator with extracted panel components. Add full role CRUD with permission editor (create, edit name/color/permissions, delete). Backend: expose role permissions in GET response, accept permissions in POST/PATCH role endpoints with BigInt validation, broadcast pushReadyPayload to all space members on role mutations.
This commit is contained in:
@@ -243,6 +243,8 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canManageRoles = (spacePerms & PermissionBits.MANAGE_ROLES) !== 0n;
|
||||||
|
|
||||||
const result: SpaceWithChannelsAndMembers = {
|
const result: SpaceWithChannelsAndMembers = {
|
||||||
...rowToSpace(server),
|
...rowToSpace(server),
|
||||||
channels: visibleChannels,
|
channels: visibleChannels,
|
||||||
@@ -253,6 +255,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
color: r.color ?? '#b9bbbe',
|
color: r.color ?? '#b9bbbe',
|
||||||
position: r.position ?? 0,
|
position: r.position ?? 0,
|
||||||
|
permissions: canManageRoles ? (r.permissions ?? '0') : undefined,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
})),
|
})),
|
||||||
myPermissions: permissionsToString(spacePerms),
|
myPermissions: permissionsToString(spacePerms),
|
||||||
@@ -289,7 +292,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (icon !== undefined) {
|
if (icon !== undefined) {
|
||||||
updates.icon = icon;
|
updates.icon = icon || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility !== undefined) {
|
if (visibility !== undefined) {
|
||||||
@@ -764,17 +767,28 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Role Management
|
// Role Management
|
||||||
|
|
||||||
// POST /api/spaces/:id/roles - Create a new role
|
// POST /api/spaces/:id/roles - Create a new role
|
||||||
app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/spaces/:id/roles', {
|
app.post<{ Params: { id: string }; Body: { name: string; color?: string; permissions?: string } }>('/api/spaces/:id/roles', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { name, color } = request.body;
|
const { name, color, permissions } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
||||||
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate permissions string is a valid bigint if provided
|
||||||
|
let permStr: string | undefined;
|
||||||
|
if (permissions !== undefined) {
|
||||||
|
try {
|
||||||
|
BigInt(permissions);
|
||||||
|
permStr = permissions;
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const roleId = generateSnowflake();
|
const roleId = generateSnowflake();
|
||||||
db.insert(schema.roles).values({
|
db.insert(schema.roles).values({
|
||||||
id: roleId,
|
id: roleId,
|
||||||
@@ -782,27 +796,60 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: name || 'new role',
|
name: name || 'new role',
|
||||||
color: color || '#b9bbbe',
|
color: color || '#b9bbbe',
|
||||||
position: 0,
|
position: 0,
|
||||||
|
permissions: permStr ?? null,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
||||||
|
|
||||||
|
// Broadcast updated state to all space members
|
||||||
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
||||||
|
for (const m of memberRows) {
|
||||||
|
connectionManager.pushReadyPayload(m.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(201).send(role);
|
return reply.code(201).send(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/spaces/:id/roles/:roleId - Update a role
|
// PATCH /api/spaces/:id/roles/:roleId - Update a role
|
||||||
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/spaces/:id/roles/:roleId', {
|
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number; permissions?: string } }>('/api/spaces/:id/roles/:roleId', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id, roleId } = request.params;
|
const { id, roleId } = request.params;
|
||||||
const updates = request.body;
|
const { name, color, position, permissions } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
||||||
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updates: Partial<typeof schema.roles.$inferInsert> = {};
|
||||||
|
if (name !== undefined) updates.name = name;
|
||||||
|
if (color !== undefined) updates.color = color;
|
||||||
|
if (position !== undefined) updates.position = position;
|
||||||
|
|
||||||
|
if (permissions !== undefined) {
|
||||||
|
try {
|
||||||
|
BigInt(permissions);
|
||||||
|
updates.permissions = permissions;
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
||||||
const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
||||||
|
|
||||||
|
// Broadcast updated state to all space members
|
||||||
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
||||||
|
for (const m of memberRows) {
|
||||||
|
connectionManager.pushReadyPayload(m.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send(updated);
|
return reply.code(200).send(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -828,6 +875,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
||||||
|
|
||||||
|
// Broadcast updated state to all space members
|
||||||
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
||||||
|
for (const m of memberRows) {
|
||||||
|
connectionManager.pushReadyPayload(m.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
VerifyPasswordResponse,
|
VerifyPasswordResponse,
|
||||||
ExploreSpace,
|
ExploreSpace,
|
||||||
JoinRequest,
|
JoinRequest,
|
||||||
|
Role,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
|
||||||
export class BackspaceApiClient {
|
export class BackspaceApiClient {
|
||||||
@@ -117,6 +118,12 @@ export class BackspaceApiClient {
|
|||||||
info: () => Promise<InstanceInfoResponse>;
|
info: () => Promise<InstanceInfoResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly roles: {
|
||||||
|
create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) => Promise<Role>;
|
||||||
|
update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) => Promise<Role>;
|
||||||
|
delete: (spaceId: string, roleId: string) => Promise<{ success: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly explore: {
|
readonly explore: {
|
||||||
list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
|
list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
|
||||||
publicJoin: (spaceId: string) => Promise<SpaceWithChannelsAndMembers>;
|
publicJoin: (spaceId: string) => Promise<SpaceWithChannelsAndMembers>;
|
||||||
@@ -299,6 +306,15 @@ export class BackspaceApiClient {
|
|||||||
info: () => request<InstanceInfoResponse>('GET', '/instance/info', undefined, false),
|
info: () => request<InstanceInfoResponse>('GET', '/instance/info', undefined, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.roles = {
|
||||||
|
create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) =>
|
||||||
|
request<Role>('POST', `/spaces/${spaceId}/roles`, data),
|
||||||
|
update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) =>
|
||||||
|
request<Role>('PATCH', `/spaces/${spaceId}/roles/${roleId}`, data),
|
||||||
|
delete: (spaceId: string, roleId: string) =>
|
||||||
|
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/roles/${roleId}`),
|
||||||
|
};
|
||||||
|
|
||||||
this.explore = {
|
this.explore = {
|
||||||
list: (q?: string, limit = 50, offset = 0) => {
|
list: (q?: string, limit = 50, offset = 0) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
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';
|
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||||
|
|
||||||
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
||||||
@@ -464,157 +465,47 @@ export function SpaceSettingsModal() {
|
|||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
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 isAdmin = useSettingsStore((s) => s.isAdmin);
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'streaming'>('overview');
|
|
||||||
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 spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'streaming'>('overview');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
const space = spaces.find(s => s.id === currentSpaceId);
|
const space = spaces.find(s => s.id === currentSpaceId);
|
||||||
const isOwnerUser = space?.ownerId === currentUser?.id;
|
|
||||||
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||||
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
|
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
|
||||||
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
|
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
|
||||||
|
|
||||||
// Assignable roles: exclude @everyone (where role.id === spaceId)
|
|
||||||
const assignableRoles = roles.filter(r => r.id !== currentSpaceId);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (space) {
|
|
||||||
setSpaceName(space.name);
|
|
||||||
}
|
|
||||||
}, [space]);
|
|
||||||
|
|
||||||
if (!space || !currentSpaceId) return null;
|
if (!space || !currentSpaceId) return null;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const tabClass = (t: typeof tab) =>
|
||||||
setError('');
|
`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||||
setIsLoading(true);
|
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||||
try {
|
}`;
|
||||||
await updateSpace(currentSpaceId, { name: spaceName.trim() });
|
|
||||||
setIsLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update space');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirmDelete) {
|
|
||||||
setConfirmDelete(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await deleteSpace(currentSpaceId);
|
|
||||||
closeModal();
|
|
||||||
navigate('/channels/@me');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete space');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMemberRoleIds = (member: typeof members[number]): Set<string> => {
|
|
||||||
// Check for pending (unsaved) changes first
|
|
||||||
const pending = pendingRoleChanges.get(member.userId);
|
|
||||||
if (pending) return pending;
|
|
||||||
return new Set(member.roles?.map(r => r.id) ?? []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoleToggle = (userId: string, roleId: string, currentRoleIds: Set<string>) => {
|
|
||||||
const updated = new Set(currentRoleIds);
|
|
||||||
if (updated.has(roleId)) {
|
|
||||||
updated.delete(roleId);
|
|
||||||
} else {
|
|
||||||
updated.add(roleId);
|
|
||||||
}
|
|
||||||
setPendingRoleChanges(prev => new Map(prev).set(userId, updated));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveRoles = async (userId: string) => {
|
|
||||||
const roleIds = pendingRoleChanges.get(userId);
|
|
||||||
if (!roleIds) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.spaces.updateMember(currentSpaceId, userId, { roleIds: Array.from(roleIds) });
|
|
||||||
setPendingRoleChanges(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(userId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
await loadSpaceDetail(currentSpaceId);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelRoleChange = (userId: string) => {
|
|
||||||
setPendingRoleChanges(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(userId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKick = async (userId: string) => {
|
|
||||||
try {
|
|
||||||
await api.spaces.removeMember(currentSpaceId, userId);
|
|
||||||
await loadSpaceDetail(currentSpaceId);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="w-32 flex-shrink-0 space-y-1">
|
<div className="w-32 flex-shrink-0 space-y-1">
|
||||||
<button
|
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||||
onClick={() => setTab('overview')}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
|
||||||
tab === 'overview' ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
{canManageSpace && (
|
{canManageSpace && (
|
||||||
<button
|
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
|
||||||
onClick={() => setTab('discovery')}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
|
||||||
tab === 'discovery' ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Discovery
|
Discovery
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||||
onClick={() => setTab('members')}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
|
||||||
tab === 'members' ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Members
|
Members
|
||||||
</button>
|
</button>
|
||||||
|
{canManageRoles && (
|
||||||
|
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
|
||||||
|
Roles
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||||
onClick={() => setTab('streaming')}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
|
||||||
tab === 'streaming' ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Streaming
|
Streaming
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -622,154 +513,11 @@ export function SpaceSettingsModal() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{error && (
|
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
|
||||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||||
)}
|
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'overview' && (
|
{tab === 'streaming' && isAdmin && <StreamingLimitsPanel />}
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
|
||||||
Space Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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={!canManageSpace}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canManageSpace && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isLoading}
|
|
||||||
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 ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-border-soft">
|
|
||||||
<h3 className="text-sm font-bold text-txt-danger mb-2">Danger Zone</h3>
|
|
||||||
<button
|
|
||||||
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 Space'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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 === space.ownerId;
|
|
||||||
const memberRoleIds = getMemberRoleIds(member);
|
|
||||||
const hasPendingChanges = pendingRoleChanges.has(member.userId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={member.userId} className="p-2 rounded hover:bg-interactive-hover">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar
|
|
||||||
src={member.user.avatar}
|
|
||||||
name={displayName}
|
|
||||||
size={32}
|
|
||||||
status={member.user.status}
|
|
||||||
user={member.user}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">{displayName}</div>
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
{isOwner && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
|
||||||
Owner
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{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"
|
|
||||||
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentSpaceId).length === 0) && (
|
|
||||||
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canManageRoles && member.userId !== currentUser?.id && !isOwner && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleKick(member.userId)}
|
|
||||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
|
||||||
>
|
|
||||||
Kick
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */}
|
|
||||||
{canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && (
|
|
||||||
<div className="mt-2 ml-10 space-y-1">
|
|
||||||
{assignableRoles.map(role => (
|
|
||||||
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={memberRoleIds.has(role.id)}
|
|
||||||
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
|
|
||||||
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
|
||||||
>
|
|
||||||
{role.name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{hasPendingChanges && (
|
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSaveRoles(member.userId)}
|
|
||||||
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancelRoleChange(member.userId)}
|
|
||||||
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'streaming' && isAdmin && (
|
|
||||||
<StreamingLimitsPanel />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Avatar } from '../../ui/Avatar';
|
||||||
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
|
import { api } from '../../../api/client';
|
||||||
|
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||||
|
import type { MemberWithUser } from '@backspace/shared';
|
||||||
|
|
||||||
|
interface MembersPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||||
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const roles = useSpaceStore((s) => s.roles);
|
||||||
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
|
const space = spaces.find((s) => s.id === spaceId);
|
||||||
|
const myPerms = spacePermissions.get(spaceId);
|
||||||
|
const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
|
||||||
|
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
||||||
|
|
||||||
|
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Assignable roles: exclude @everyone (where role.id === spaceId)
|
||||||
|
const assignableRoles = roles.filter((r) => r.id !== spaceId);
|
||||||
|
|
||||||
|
if (!space) return null;
|
||||||
|
|
||||||
|
const getMemberRoleIds = (member: MemberWithUser): Set<string> => {
|
||||||
|
const pending = pendingRoleChanges.get(member.userId);
|
||||||
|
if (pending) return pending;
|
||||||
|
return new Set(member.roles?.map((r) => r.id) ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleToggle = (userId: string, roleId: string, currentRoleIds: Set<string>) => {
|
||||||
|
const updated = new Set(currentRoleIds);
|
||||||
|
if (updated.has(roleId)) {
|
||||||
|
updated.delete(roleId);
|
||||||
|
} else {
|
||||||
|
updated.add(roleId);
|
||||||
|
}
|
||||||
|
setPendingRoleChanges((prev) => new Map(prev).set(userId, updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveRoles = async (userId: string) => {
|
||||||
|
const roleIds = pendingRoleChanges.get(userId);
|
||||||
|
if (!roleIds) return;
|
||||||
|
try {
|
||||||
|
await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
|
||||||
|
setPendingRoleChanges((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await loadSpaceDetail(spaceId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRoleChange = (userId: string) => {
|
||||||
|
setPendingRoleChanges((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKick = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
await api.spaces.removeMember(spaceId, userId);
|
||||||
|
await loadSpaceDetail(spaceId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
<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 === space.ownerId;
|
||||||
|
const memberRoleIds = getMemberRoleIds(member);
|
||||||
|
const hasPendingChanges = pendingRoleChanges.has(member.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={member.userId} className="p-2 rounded hover:bg-interactive-hover">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar
|
||||||
|
src={member.user.avatar}
|
||||||
|
name={displayName}
|
||||||
|
size={32}
|
||||||
|
status={member.user.status}
|
||||||
|
user={member.user}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{displayName}</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{isOwner && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||||
|
Owner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member.roles?.filter((r) => r.id !== spaceId).map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||||
|
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
|
||||||
|
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleKick(member.userId)}
|
||||||
|
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Kick
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */}
|
||||||
|
{canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && (
|
||||||
|
<div className="mt-2 ml-10 space-y-1">
|
||||||
|
{assignableRoles.map((role) => (
|
||||||
|
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberRoleIds.has(role.id)}
|
||||||
|
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
|
||||||
|
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{hasPendingChanges && (
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRoles(member.userId)}
|
||||||
|
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelRoleChange(member.userId)}
|
||||||
|
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
|
import { Avatar } from '../../ui/Avatar';
|
||||||
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../../api/client';
|
||||||
|
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||||
|
|
||||||
|
interface OverviewPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||||
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
|
const updateSpace = useSpaceStore((s) => s.updateSpace);
|
||||||
|
const deleteSpace = useSpaceStore((s) => s.deleteSpace);
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const space = spaces.find((s) => s.id === spaceId);
|
||||||
|
const isOwner = space?.ownerId === currentUser?.id;
|
||||||
|
const myPerms = spacePermissions.get(spaceId);
|
||||||
|
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
|
||||||
|
|
||||||
|
const [spaceName, setSpaceName] = useState(space?.name ?? '');
|
||||||
|
// null = no change, '' = remove icon, 'filename.png' = new icon uploaded
|
||||||
|
const [iconFilename, setIconFilename] = useState<string | null>(null);
|
||||||
|
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||||
|
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||||
|
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (space) {
|
||||||
|
setSpaceName(space.name);
|
||||||
|
// Reset icon state when space data changes externally
|
||||||
|
setIconFilename(null);
|
||||||
|
if (iconPreview) {
|
||||||
|
URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconPreview(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [space?.name, space?.icon]);
|
||||||
|
|
||||||
|
if (!space) return null;
|
||||||
|
|
||||||
|
const hasNameChange = spaceName.trim() !== space.name;
|
||||||
|
const hasIconChange = iconFilename !== null;
|
||||||
|
const hasChanges = hasNameChange || hasIconChange;
|
||||||
|
|
||||||
|
const currentIconUrl = space.icon ? api.uploads.url(space.icon) : null;
|
||||||
|
|
||||||
|
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setCropSrc(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCropComplete = async (blob: Blob) => {
|
||||||
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
setIconPreview(previewUrl);
|
||||||
|
setCropSrc(null);
|
||||||
|
|
||||||
|
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
||||||
|
setUploadingIcon(true);
|
||||||
|
try {
|
||||||
|
const attachment = await api.uploads.upload(file);
|
||||||
|
setIconFilename(attachment.filename);
|
||||||
|
} catch {
|
||||||
|
setSaveError('Failed to upload icon');
|
||||||
|
setIconPreview(null);
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
} finally {
|
||||||
|
setUploadingIcon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveIcon = () => {
|
||||||
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconPreview(null);
|
||||||
|
setIconFilename(''); // '' signals removal
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
setSaveSuccess(false);
|
||||||
|
try {
|
||||||
|
const updates: { name?: string; icon?: string } = {};
|
||||||
|
if (hasNameChange) updates.name = spaceName.trim();
|
||||||
|
if (hasIconChange) {
|
||||||
|
// Empty string signals icon removal to the backend (sets to null)
|
||||||
|
updates.icon = iconFilename === '' ? '' : iconFilename!;
|
||||||
|
}
|
||||||
|
await updateSpace(spaceId, updates);
|
||||||
|
setIconFilename(null);
|
||||||
|
if (iconPreview) {
|
||||||
|
URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconPreview(null);
|
||||||
|
}
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
setSpaceName(space.name);
|
||||||
|
setIconFilename(null);
|
||||||
|
if (iconPreview) {
|
||||||
|
URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconPreview(null);
|
||||||
|
}
|
||||||
|
setSaveError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteSpace(spaceId);
|
||||||
|
closeModal();
|
||||||
|
navigate('/channels/@me');
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : 'Failed to delete space');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine what icon to show: preview of pending upload, or current space icon
|
||||||
|
const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl);
|
||||||
|
const displayIconName = space.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Space Icon */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Space Icon
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => canManageSpace && fileInputRef.current?.click()}
|
||||||
|
disabled={!canManageSpace || uploadingIcon}
|
||||||
|
className={`relative w-16 h-16 rounded-full bg-surface-input border-2 border-dashed border-border-subtle flex items-center justify-center overflow-hidden group ${
|
||||||
|
canManageSpace ? 'hover:border-accent-primary cursor-pointer' : 'cursor-default'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
{displayIconSrc ? (
|
||||||
|
<>
|
||||||
|
<img src={displayIconSrc} alt="Space icon" className="w-full h-full object-cover" />
|
||||||
|
{canManageSpace && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Avatar name={displayIconName} size={64} />
|
||||||
|
)}
|
||||||
|
{uploadingIcon && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleIconSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{canManageSpace && (displayIconSrc || space.icon) && iconFilename !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveIcon}
|
||||||
|
className="text-xs text-txt-tertiary hover:text-txt-danger transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Space Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Space Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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={!canManageSpace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Discard */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{canManageSpace && hasChanges && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || uploadingIcon || !spaceName.trim()}
|
||||||
|
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="pt-4 border-t border-border-soft">
|
||||||
|
<h3 className="text-sm font-bold text-txt-danger mb-2">Danger Zone</h3>
|
||||||
|
<button
|
||||||
|
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 Space'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={cropSrc !== null}
|
||||||
|
onClose={() => setCropSrc(null)}
|
||||||
|
imageSrc={cropSrc ?? ''}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
title="Crop Space Icon"
|
||||||
|
cropShape="round"
|
||||||
|
aspectRatio={1}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
|
import { api } from '../../../api/client';
|
||||||
|
import { PermissionBits, stringToPermissions, permissionsToString } from '../../../utils/permissions';
|
||||||
|
import type { Role } from '@backspace/shared';
|
||||||
|
|
||||||
|
// ─── Permission display groups ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PermDef {
|
||||||
|
bit: bigint;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'General',
|
||||||
|
perms: [
|
||||||
|
{ bit: PermissionBits.ADMINISTRATOR, label: 'Administrator' },
|
||||||
|
{ bit: PermissionBits.VIEW_CHANNEL, label: 'View Channels' },
|
||||||
|
{ bit: PermissionBits.MANAGE_CHANNELS, label: 'Manage Channels' },
|
||||||
|
{ bit: PermissionBits.MANAGE_ROLES, label: 'Manage Roles' },
|
||||||
|
{ bit: PermissionBits.MANAGE_SPACE, label: 'Manage Space' },
|
||||||
|
{ bit: PermissionBits.CREATE_INVITE, label: 'Create Invite' },
|
||||||
|
{ bit: PermissionBits.KICK_MEMBERS, label: 'Kick Members' },
|
||||||
|
{ bit: PermissionBits.BAN_MEMBERS, label: 'Ban Members' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Text',
|
||||||
|
perms: [
|
||||||
|
{ bit: PermissionBits.SEND_MESSAGES, label: 'Send Messages' },
|
||||||
|
{ bit: PermissionBits.MANAGE_MESSAGES, label: 'Manage Messages' },
|
||||||
|
{ bit: PermissionBits.ATTACH_FILES, label: 'Attach Files' },
|
||||||
|
{ bit: PermissionBits.READ_MESSAGE_HISTORY, label: 'Read Message History' },
|
||||||
|
{ bit: PermissionBits.ADD_REACTIONS, label: 'Add Reactions' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Voice',
|
||||||
|
perms: [
|
||||||
|
{ bit: PermissionBits.CONNECT, label: 'Connect' },
|
||||||
|
{ bit: PermissionBits.SPEAK, label: 'Speak' },
|
||||||
|
{ bit: PermissionBits.MUTE_MEMBERS, label: 'Mute Members' },
|
||||||
|
{ bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' },
|
||||||
|
{ bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' },
|
||||||
|
{ bit: PermissionBits.USE_VOICE_ACTIVITY, label: 'Voice Activity' },
|
||||||
|
{ bit: PermissionBits.STREAM, label: 'Stream' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
'#b9bbbe', '#a5f3c4', '#ffc9a9', '#c4b5fd', '#93c5fd',
|
||||||
|
'#fbbf24', '#fda4af', '#f87171', '#60a5fa', '#34d399',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RolesPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolesPanel({ spaceId }: RolesPanelProps) {
|
||||||
|
const roles = useSpaceStore((s) => s.roles);
|
||||||
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
|
|
||||||
|
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Sort: non-everyone roles by position desc, @everyone always last
|
||||||
|
const sortedRoles = [...roles].sort((a, b) => {
|
||||||
|
const aIsEveryone = a.id === spaceId;
|
||||||
|
const bIsEveryone = b.id === spaceId;
|
||||||
|
if (aIsEveryone) return 1;
|
||||||
|
if (bIsEveryone) return -1;
|
||||||
|
return b.position - a.position;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateRole = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const newRole = await api.roles.create(spaceId, { name: 'new role' });
|
||||||
|
await loadSpaceDetail(spaceId);
|
||||||
|
setEditingRoleId(newRole.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create role');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRoleId) {
|
||||||
|
const role = roles.find((r) => r.id === editingRoleId);
|
||||||
|
if (!role) {
|
||||||
|
setEditingRoleId(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RoleEditView
|
||||||
|
role={role}
|
||||||
|
spaceId={spaceId}
|
||||||
|
onBack={() => setEditingRoleId(null)}
|
||||||
|
onDeleted={() => setEditingRoleId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateRole}
|
||||||
|
disabled={creating}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{creating ? 'Creating...' : 'Create Role'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sortedRoles.map((role) => {
|
||||||
|
const isEveryone = role.id === spaceId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
onClick={() => setEditingRoleId(role.id)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 rounded hover:bg-interactive-hover transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: role.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-txt-primary truncate">
|
||||||
|
{isEveryone ? '@everyone' : role.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-txt-tertiary group-hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Role Edit View ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RoleEditViewProps {
|
||||||
|
role: Role;
|
||||||
|
spaceId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||||
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
|
const isEveryone = role.id === spaceId;
|
||||||
|
|
||||||
|
const [draftName, setDraftName] = useState(role.name);
|
||||||
|
const [draftColor, setDraftColor] = useState(role.color);
|
||||||
|
const [draftPermissions, setDraftPermissions] = useState<bigint>(
|
||||||
|
stringToPermissions(role.permissions)
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const hasNameChange = !isEveryone && draftName.trim() !== role.name;
|
||||||
|
const hasColorChange = !isEveryone && draftColor !== role.color;
|
||||||
|
const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0');
|
||||||
|
const hasChanges = hasNameChange || hasColorChange || hasPermChange;
|
||||||
|
|
||||||
|
const togglePermission = (bit: bigint) => {
|
||||||
|
setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
setSaveSuccess(false);
|
||||||
|
try {
|
||||||
|
const data: { name?: string; color?: string; permissions?: string } = {};
|
||||||
|
if (hasNameChange) data.name = draftName.trim();
|
||||||
|
if (hasColorChange) data.color = draftColor;
|
||||||
|
if (hasPermChange) data.permissions = permissionsToString(draftPermissions);
|
||||||
|
await api.roles.update(spaceId, role.id, data);
|
||||||
|
await loadSpaceDetail(spaceId);
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : 'Failed to save role');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
setDraftName(role.name);
|
||||||
|
setDraftColor(role.color);
|
||||||
|
setDraftPermissions(stringToPermissions(role.permissions));
|
||||||
|
setSaveError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
setSaveError('');
|
||||||
|
try {
|
||||||
|
await api.roles.delete(spaceId, role.id);
|
||||||
|
await loadSpaceDetail(spaceId);
|
||||||
|
onDeleted();
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : 'Failed to delete role');
|
||||||
|
setConfirmDelete(false);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to roles
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Role Name (not editable for @everyone) */}
|
||||||
|
{!isEveryone && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
|
Role Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Color (not editable for @everyone) */}
|
||||||
|
{!isEveryone && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
|
Role Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{PRESET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setDraftColor(c)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-all ${
|
||||||
|
draftColor === c ? 'border-white scale-110' : 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="relative w-7 h-7 rounded-full border-2 border-border-subtle hover:border-accent-primary transition-colors cursor-pointer overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={draftColor}
|
||||||
|
onChange={(e) => setDraftColor(e.target.value)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-full bg-gradient-to-br from-red-400 via-green-400 to-blue-400" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary font-mono"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Permissions
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{PERMISSION_GROUPS.map((group) => (
|
||||||
|
<div key={group.name}>
|
||||||
|
<div className="text-[11px] text-txt-tertiary font-medium mb-1.5 uppercase tracking-wider">
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.perms.map((perm) => {
|
||||||
|
const isOn = (draftPermissions & perm.bit) !== 0n;
|
||||||
|
const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={perm.label}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-interactive-hover cursor-pointer group/perm"
|
||||||
|
>
|
||||||
|
<span className={`text-sm ${isAdmin ? 'text-txt-danger font-medium' : 'text-txt-primary'}`}>
|
||||||
|
{perm.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePermission(perm.bit);
|
||||||
|
}}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${
|
||||||
|
isOn ? 'bg-accent-primary' : 'bg-interactive-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
isOn ? 'translate-x-4' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Discard */}
|
||||||
|
{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">Role saved</div>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||||
|
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Role (not for @everyone) */}
|
||||||
|
{!isEveryone && (
|
||||||
|
<div className="pt-4 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-1.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : confirmDelete ? 'Click again to confirm' : 'Delete Role'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user