388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { useRoleStore, type Role, type CreateRoleData, type UpdateRoleData } from '../stores/role.ts';
|
|
|
|
// Permission bitflags
|
|
const PERMS = {
|
|
VIEW_CHANNEL: 1,
|
|
SEND_MESSAGES: 2,
|
|
MANAGE_MESSAGES: 4,
|
|
KICK_MEMBERS: 8,
|
|
BAN_MEMBERS: 16,
|
|
MANAGE_SERVER: 32,
|
|
MANAGE_CHANNELS: 64,
|
|
ADMINISTRATOR: 128,
|
|
CONNECT_VOICE: 256,
|
|
SPEAK_VOICE: 512,
|
|
SHARE_SCREEN: 1024,
|
|
MUTE_MEMBERS: 2048,
|
|
CREATE_INSTANT_INVITE: 4096,
|
|
CHANGE_NICKNAME: 8192,
|
|
MANAGE_NICKNAMES: 16384,
|
|
MANAGE_ROLES: 32768,
|
|
MANAGE_WEBHOOKS: 65536,
|
|
EMBED_LINKS: 131072,
|
|
ATTACH_FILES: 262144,
|
|
ADD_REACTIONS: 524288,
|
|
USE_EXTERNAL_EMOJIS: 1048576,
|
|
MENTION_EVERYONE: 2097152,
|
|
} as const;
|
|
|
|
const PERM_CATEGORIES = [
|
|
{
|
|
name: 'General',
|
|
perms: [
|
|
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
|
|
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
|
|
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_MESSAGES },
|
|
{ key: 'ADD_REACTIONS', label: 'Add Reactions', flag: PERMS.ADD_REACTIONS },
|
|
{ key: 'EMBED_LINKS', label: 'Embed Links', flag: PERMS.EMBED_LINKS },
|
|
{ key: 'ATTACH_FILES', label: 'Attach Files', flag: PERMS.ATTACH_FILES },
|
|
{ key: 'MENTION_EVERYONE', label: 'Mention @everyone', flag: PERMS.MENTION_EVERYONE },
|
|
{ key: 'USE_EXTERNAL_EMOJIS', label: 'Use External Emojis', flag: PERMS.USE_EXTERNAL_EMOJIS },
|
|
],
|
|
},
|
|
{
|
|
name: 'Moderation',
|
|
perms: [
|
|
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
|
|
{ key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_MEMBERS },
|
|
{ key: 'MUTE_MEMBERS', label: 'Mute Members', flag: PERMS.MUTE_MEMBERS },
|
|
{ key: 'MANAGE_NICKNAMES', label: 'Manage Nicknames', flag: PERMS.MANAGE_NICKNAMES },
|
|
{ key: 'CHANGE_NICKNAME', label: 'Change Nickname', flag: PERMS.CHANGE_NICKNAME },
|
|
],
|
|
},
|
|
{
|
|
name: 'Server',
|
|
perms: [
|
|
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
|
|
{ key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS },
|
|
{ key: 'MANAGE_ROLES', label: 'Manage Roles', flag: PERMS.MANAGE_ROLES },
|
|
{ key: 'MANAGE_WEBHOOKS', label: 'Manage Webhooks', flag: PERMS.MANAGE_WEBHOOKS },
|
|
{ key: 'CREATE_INSTANT_INVITE', label: 'Create Instant Invite', flag: PERMS.CREATE_INSTANT_INVITE },
|
|
{ key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR },
|
|
],
|
|
},
|
|
{
|
|
name: 'Voice',
|
|
perms: [
|
|
{ key: 'CONNECT_VOICE', label: 'Connect Voice', flag: PERMS.CONNECT_VOICE },
|
|
{ key: 'SPEAK_VOICE', label: 'Speak Voice', flag: PERMS.SPEAK_VOICE },
|
|
{ key: 'SHARE_SCREEN', label: 'Share Screen', flag: PERMS.SHARE_SCREEN },
|
|
],
|
|
},
|
|
];
|
|
|
|
const GRUVBOX_COLORS = [
|
|
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
|
'#d3869b', '#8ec07c', '#fe8019', '#ebdbb2',
|
|
'#a89984', '#928374', '#282828',
|
|
];
|
|
|
|
function hasPerm(permissions: number, flag: number): boolean {
|
|
return (permissions & flag) !== 0;
|
|
}
|
|
|
|
function permSummary(permissions: number): string {
|
|
if (hasPerm(permissions, PERMS.ADMINISTRATOR)) return 'ADMINISTRATOR';
|
|
const active: string[] = [];
|
|
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
|
|
if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN');
|
|
if (hasPerm(permissions, PERMS.MANAGE_ROLES)) active.push('MGR_ROLES');
|
|
if (hasPerm(permissions, PERMS.MANAGE_MESSAGES)) active.push('MGR_MSG');
|
|
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
|
|
if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN');
|
|
if (hasPerm(permissions, PERMS.MUTE_MEMBERS)) active.push('MUTE');
|
|
return active.length > 0 ? active.join('+') : 'BASIC';
|
|
}
|
|
|
|
interface RoleFormProps {
|
|
serverId: string;
|
|
role?: Role;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function RoleForm({ serverId, role, onClose }: RoleFormProps) {
|
|
const createRole = useRoleStore((s) => s.createRole);
|
|
const updateRole = useRoleStore((s) => s.updateRole);
|
|
const loading = useRoleStore((s) => s.loading);
|
|
|
|
const [name, setName] = useState(role?.name ?? '');
|
|
const [color, setColor] = useState(role?.color ?? '#ebdbb2');
|
|
const [position, setPosition] = useState(role?.position ?? 0);
|
|
const [permissions, setPermissions] = useState(role?.permissions ?? 0);
|
|
|
|
const togglePerm = useCallback((flag: number) => {
|
|
setPermissions((prev) => prev ^ flag);
|
|
}, []);
|
|
|
|
const handleAdminToggle = useCallback(() => {
|
|
setPermissions((prev) => {
|
|
if (hasPerm(prev, PERMS.ADMINISTRATOR)) return 0;
|
|
return 0xFFFFFFFF; // grant all
|
|
});
|
|
}, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) return;
|
|
try {
|
|
if (role) {
|
|
const data: UpdateRoleData = { name: name.trim(), color, permissions, position };
|
|
await updateRole(serverId, role.id, data);
|
|
} else {
|
|
const data: CreateRoleData = { name: name.trim(), color, permissions, position };
|
|
await createRole(serverId, data);
|
|
}
|
|
onClose();
|
|
} catch {
|
|
// error in store
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
<p className="text-gb-orange font-mono text-sm mb-4">
|
|
{'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'}
|
|
</p>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value.slice(0, 64))}
|
|
maxLength={64}
|
|
className="terminal-input w-full"
|
|
placeholder="role-name"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COLOR:</label>
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{GRUVBOX_COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setColor(c)}
|
|
className={`w-7 h-7 border-2 transition-all ${
|
|
color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
|
}`}
|
|
style={{ backgroundColor: c }}
|
|
title={c}
|
|
/>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
className="terminal-input w-full text-xs"
|
|
placeholder="#ebdbb2"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">POSITION:</label>
|
|
<input
|
|
type="number"
|
|
value={position}
|
|
onChange={(e) => setPosition(parseInt(e.target.value) || 0)}
|
|
className="terminal-input w-24"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-gb-fg-f mb-2 font-mono text-xs">PERMISSIONS:</label>
|
|
<div className="space-y-3">
|
|
{/* Administrator all-or-nothing */}
|
|
<div className="border border-gb-bg-t p-3">
|
|
<label className="flex items-center gap-2 cursor-pointer font-mono text-xs">
|
|
<input
|
|
type="checkbox"
|
|
checked={hasPerm(permissions, PERMS.ADMINISTRATOR)}
|
|
onChange={handleAdminToggle}
|
|
className="accent-gb-red"
|
|
/>
|
|
<span className="text-gb-red">Administrator (grants all permissions)</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Category groups */}
|
|
{!hasPerm(permissions, PERMS.ADMINISTRATOR) &&
|
|
PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => (
|
|
<div key={cat.name} className="border border-gb-bg-t p-3">
|
|
<p className="text-gb-aqua font-mono text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
|
<div className="space-y-1">
|
|
{cat.perms
|
|
.filter((p) => p.key !== 'ADMINISTRATOR')
|
|
.map((p) => (
|
|
<label
|
|
key={p.key}
|
|
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={hasPerm(permissions, p.flag)}
|
|
onChange={() => togglePerm(p.flag)}
|
|
className="accent-gb-orange"
|
|
/>
|
|
<span className="text-gb-fg">{p.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button type="submit" className="terminal-button" disabled={loading || !name.trim()}>
|
|
{loading ? '[SAVING...]' : '[SAVE]'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
|
>
|
|
[CANCEL]
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RoleManager() {
|
|
const { serverId } = useParams<{ serverId: string }>();
|
|
const roles = useRoleStore((s) => s.roles);
|
|
const loading = useRoleStore((s) => s.loading);
|
|
const error = useRoleStore((s) => s.error);
|
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
|
const deleteRole = useRoleStore((s) => s.deleteRole);
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (serverId) fetchRoles(serverId);
|
|
}, [serverId, fetchRoles]);
|
|
|
|
const handleDelete = async (role: Role) => {
|
|
if (!serverId) return;
|
|
if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
await deleteRole(serverId, role.id);
|
|
} catch {
|
|
// error in store
|
|
}
|
|
};
|
|
|
|
if (!serverId) {
|
|
return (
|
|
<div className="h-full w-full bg-gb-bg p-8 flex items-center justify-center">
|
|
<p className="text-gb-red font-mono">ERR: no server selected</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
|
<div className="max-w-3xl mx-auto">
|
|
<div className="border border-gb-bg-t p-6">
|
|
<pre className="text-gb-orange font-mono text-center mb-6">
|
|
{'┌──────────────────────────────────┐\n'}
|
|
{'│ === ROLE MANAGER === │\n'}
|
|
{'└──────────────────────────────────┘'}
|
|
</pre>
|
|
|
|
{error && (
|
|
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
|
)}
|
|
|
|
<div className="mb-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
|
className="terminal-button"
|
|
>
|
|
[CREATE ROLE]
|
|
</button>
|
|
</div>
|
|
|
|
{loading && roles.length === 0 && (
|
|
<p className="text-gb-fg-f font-mono text-sm">[loading roles...]</p>
|
|
)}
|
|
{!loading && roles.length === 0 && (
|
|
<p className="text-gb-fg-f font-mono text-sm">[no roles configured]</p>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{roles.map((role) => (
|
|
<div key={role.id} className="border border-gb-bg-t p-4">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span
|
|
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
|
/>
|
|
<span
|
|
className="font-mono text-sm font-bold truncate"
|
|
style={{ color: role.color || '#ebdbb2' }}
|
|
>
|
|
{role.name}
|
|
</span>
|
|
{role.is_default && (
|
|
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
|
)}
|
|
<span className="text-gb-fg-f font-mono text-xs ml-auto shrink-0">
|
|
POS:{role.position}
|
|
</span>
|
|
</div>
|
|
<p className="text-gb-fg-f font-mono text-xs mb-3">
|
|
perms: {permSummary(role.permissions)}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setEditingRole(role); setShowForm(true); }}
|
|
className="terminal-button text-xs"
|
|
>
|
|
[EDIT]
|
|
</button>
|
|
{!role.is_default && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDelete(role)}
|
|
className="terminal-button text-xs hover:!text-gb-red"
|
|
>
|
|
[DELETE]
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
|
{'<'} [BACK TO CHAT]
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<RoleForm
|
|
serverId={serverId}
|
|
role={editingRole ?? undefined}
|
|
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|