Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills that float at the bottom of scrollable modal content. Make SpaceSettings tab sidebar sticky with glass material. Convert RoleEditView "Back to roles" into a sticky glass pill at the top. UserSettings Log Out + Save always visible in a glass pill with separator. Also includes floating position hook and popover/tooltip improvements from prior work.
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
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 */}
|
|
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
|
<button
|
|
onClick={onBack}
|
|
className="glass-bubble rounded-full px-3 py-1.5 flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors pointer-events-auto"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
{/* 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="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>
|
|
</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>
|
|
);
|
|
}
|