fix: federated space icon display/upload in settings, restyle settings panels
Fix three federation bugs in Space Settings: icon URL was double-wrapped through home API, upload targeted home instance instead of remote, and updateSpace used home API client. Also restyle settings panels (User, Space, Members, Roles, Connected Instances) with consistent section headers, grouped cards, and improved layout.
This commit is contained in:
@@ -24,6 +24,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
||||
|
||||
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Assignable roles: exclude @everyone (where role.id === spaceId)
|
||||
@@ -57,6 +58,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
next.delete(userId);
|
||||
return next;
|
||||
});
|
||||
setExpandedMemberId(null);
|
||||
await loadSpaceDetail(spaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
||||
@@ -80,105 +82,139 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const canExpandMember = (member: MemberWithUser) =>
|
||||
canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0;
|
||||
|
||||
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 }}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||
)}
|
||||
<p className="text-xs text-txt-tertiary">Manage members of this space. Click a member to edit their roles.</p>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Members ({members.length})
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{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);
|
||||
const isExpanded = expandedMemberId === member.userId;
|
||||
const expandable = canExpandMember(member);
|
||||
|
||||
return (
|
||||
<div key={member.userId}>
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 rounded transition-colors ${
|
||||
expandable ? 'cursor-pointer hover:bg-interactive-hover' : ''
|
||||
} ${isExpanded ? 'bg-interactive-hover' : ''}`}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
setExpandedMemberId(isExpanded ? null : member.userId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={member.user.status}
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{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>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
{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>
|
||||
Kick
|
||||
</button>
|
||||
)}
|
||||
{expandable && (
|
||||
<svg
|
||||
className={`w-4 h-4 text-txt-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</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>
|
||||
{/* Role checkboxes — only shown when expanded */}
|
||||
{isExpanded && expandable && (
|
||||
<div className="mt-1 mb-1 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../../stores/authStore';
|
||||
import { useUIStore } from '../../../stores/uiStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../../api/client';
|
||||
import { getApiForOrigin } from '../../../stores/spaceStore';
|
||||
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||
|
||||
interface OverviewPanelProps {
|
||||
@@ -56,7 +57,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
const hasIconChange = iconFilename !== null;
|
||||
const hasChanges = hasNameChange || hasIconChange;
|
||||
|
||||
const currentIconUrl = space.icon ? api.uploads.url(space.icon) : null;
|
||||
const currentIconUrl = space.icon
|
||||
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
||||
: null;
|
||||
|
||||
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -76,7 +79,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
||||
setUploadingIcon(true);
|
||||
try {
|
||||
const attachment = await api.uploads.upload(file);
|
||||
const spaceApi = getApiForOrigin(space._instanceOrigin);
|
||||
const attachment = await spaceApi.uploads.upload(file);
|
||||
setIconFilename(attachment.filename);
|
||||
} catch {
|
||||
setSaveError('Failed to upload icon');
|
||||
@@ -149,10 +153,14 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{/* Space Identity */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Space Identity</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
{/* Space Icon */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Icon
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -209,7 +217,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
|
||||
{/* Space Name */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Name
|
||||
</label>
|
||||
<input
|
||||
@@ -220,6 +228,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
disabled={!canManageSpace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Discard */}
|
||||
{saveError && (
|
||||
@@ -228,6 +238,22 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
{saveSuccess && (
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||
)}
|
||||
{/* Danger Zone */}
|
||||
{isOwner && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-danger uppercase tracking-wider mb-1.5">Danger Zone</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<p className="text-xs text-txt-tertiary mb-3">Permanently delete this space and all its data. This cannot be undone.</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{canManageSpace && hasChanges && (
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
@@ -249,19 +275,6 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
</div>
|
||||
</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
|
||||
|
||||
@@ -108,7 +108,7 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||
)}
|
||||
@@ -127,36 +127,42 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Roles</div>
|
||||
<p className="text-xs text-txt-tertiary mb-2">Roles define what permissions members have. Higher roles take priority.</p>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -190,12 +196,14 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
const hasColorChange = !isEveryone && draftColor !== role.color;
|
||||
const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0');
|
||||
const hasChanges = hasNameChange || hasColorChange || hasPermChange;
|
||||
const showPill = hasChanges || !isEveryone;
|
||||
|
||||
const togglePermission = (bit: bigint) => {
|
||||
setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setConfirmDelete(false);
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
@@ -219,6 +227,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
setDraftName(role.name);
|
||||
setDraftColor(role.color);
|
||||
setDraftPermissions(stringToPermissions(role.permissions));
|
||||
setConfirmDelete(false);
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
@@ -242,7 +251,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{/* Back button */}
|
||||
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
||||
<button
|
||||
@@ -256,149 +265,153 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Role Name (not editable for @everyone) */}
|
||||
{/* Identity card (Name + Color — not shown 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">
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Identity</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Role Name
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={draftColor}
|
||||
onChange={(e) => setDraftColor(e.target.value)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
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 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>
|
||||
<label className="block text-xs text-txt-secondary 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>
|
||||
</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"
|
||||
{/* Permission groups — each gets its own section card */}
|
||||
{PERMISSION_GROUPS.map((group) => (
|
||||
<div key={group.name}>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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'
|
||||
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
|
||||
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>
|
||||
</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 && (
|
||||
{showPill && (
|
||||
<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={handleDiscard}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||
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 className={`glass-bubble rounded-full px-4 py-2 flex items-center gap-2 pointer-events-auto${
|
||||
isEveryone ? ' animate-slide-up' : ''
|
||||
}`}>
|
||||
{hasChanges && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{!isEveryone && hasChanges && (
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
)}
|
||||
{!isEveryone && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors disabled:opacity-50 ${
|
||||
confirmDelete
|
||||
? 'bg-accent-rose/15 text-accent-rose'
|
||||
: 'text-accent-rose hover:bg-accent-rose/10'
|
||||
}`}
|
||||
>
|
||||
{deleting ? 'Deleting...' : confirmDelete ? 'Confirm?' : 'Delete Role'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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