feat(ui): Discord-style roles/channel perms + IDE themes
Split-pane role editor with tri-state channel overrides (roles/members). CSS-var themes (Gruvbox default + 9 IDE palettes) in top bar and settings.
This commit is contained in:
+29
-2
@@ -1,13 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="gruvbox">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<script>
|
||||
// Apply saved theme before paint (migrates legacy light/dark).
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('dumpster-theme');
|
||||
if (t === 'light') t = 'gruvbox-light';
|
||||
else if (t === 'dark' || !t) t = 'gruvbox';
|
||||
var ok = {
|
||||
gruvbox: 1,
|
||||
'gruvbox-light': 1,
|
||||
'one-dark': 1,
|
||||
dracula: 1,
|
||||
nord: 1,
|
||||
'tokyo-night': 1,
|
||||
catppuccin: 1,
|
||||
'solarized-dark': 1,
|
||||
monokai: 1,
|
||||
'github-dark': 1,
|
||||
};
|
||||
if (!ok[t]) t = 'gruvbox';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.classList.add(t === 'gruvbox-light' ? 'light' : 'dark');
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'gruvbox');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||
|
||||
<!-- Apple -->
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
|
||||
import { useRoleStore } from "../stores/role.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
import {
|
||||
usePermissionStore,
|
||||
PERMS,
|
||||
PERM_CATEGORIES,
|
||||
PERMISSION_LABELS,
|
||||
type ChannelOverride,
|
||||
type PermissionKey,
|
||||
} from '../stores/permissions.ts';
|
||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||
import { useMemberStore, type Member } from '../stores/member.ts';
|
||||
|
||||
interface ChannelSettingsModalProps {
|
||||
serverId: string;
|
||||
@@ -12,226 +18,625 @@ interface ChannelSettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'overview' | 'permissions';
|
||||
type TargetMode = 'role' | 'user';
|
||||
type TargetType = 'role' | 'user';
|
||||
type TriState = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
interface OverrideFormData {
|
||||
targetType: TargetMode;
|
||||
targetId: string;
|
||||
interface DraftOverride {
|
||||
target_type: TargetType;
|
||||
target_id: string;
|
||||
allow: number;
|
||||
deny: number;
|
||||
}
|
||||
|
||||
function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number; onChange: (allow: number, deny: number) => void }) {
|
||||
const toggle = (flag: number, state: 'allow' | 'deny' | 'inherit') => {
|
||||
let nextAllow = allow;
|
||||
let nextDeny = deny;
|
||||
if (state === 'allow') {
|
||||
nextAllow |= flag;
|
||||
nextDeny &= ~flag;
|
||||
} else if (state === 'deny') {
|
||||
nextAllow &= ~flag;
|
||||
nextDeny |= flag;
|
||||
} else {
|
||||
nextAllow &= ~flag;
|
||||
nextDeny &= ~flag;
|
||||
}
|
||||
onChange(nextAllow, nextDeny);
|
||||
};
|
||||
// Channel-scoped perms only (admin / manage-server stay at role level).
|
||||
const CHANNEL_PERM_KEYS = new Set<PermissionKey>([
|
||||
'VIEW_CHANNEL',
|
||||
'SEND_MESSAGES',
|
||||
'MANAGE_MESSAGES',
|
||||
'ADD_REACTIONS',
|
||||
'EMBED_LINKS',
|
||||
'ATTACH_FILES',
|
||||
'MENTION_EVERYONE',
|
||||
'USE_EXTERNAL_EMOJIS',
|
||||
'CREATE_INSTANT_INVITE',
|
||||
'CONNECT_VOICE',
|
||||
'SPEAK_VOICE',
|
||||
'SHARE_SCREEN',
|
||||
'MUTE_MEMBERS',
|
||||
]);
|
||||
|
||||
const state = (flag: number): 'allow' | 'deny' | 'inherit' => {
|
||||
if (hasPermission(allow, flag)) return 'allow';
|
||||
if (hasPermission(deny, flag)) return 'deny';
|
||||
return 'inherit';
|
||||
};
|
||||
const CHANNEL_PERM_CATEGORIES = PERM_CATEGORIES.map((cat) => ({
|
||||
...cat,
|
||||
keys: cat.keys.filter((k) => CHANNEL_PERM_KEYS.has(k)),
|
||||
})).filter((cat) => cat.keys.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto border border-gb-bg-t p-2">
|
||||
{(Object.keys(PERMISSION_LABELS) as (keyof typeof PERMISSION_LABELS)[]).map((key) => {
|
||||
const flag = PERMS[key];
|
||||
const s = state(flag);
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-xs font-mono py-1">
|
||||
<span className="text-gb-fg">{PERMISSION_LABELS[key]}</span>
|
||||
<div className="flex gap-1">
|
||||
{(['inherit', 'allow', 'deny'] as const).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => toggle(flag, opt)}
|
||||
className={`px-2 py-0.5 border ${
|
||||
s === opt
|
||||
? opt === 'allow' ? 'bg-gb-green text-gb-bg border-gb-green' :
|
||||
opt === 'deny' ? 'bg-gb-red text-gb-bg border-gb-red' :
|
||||
'bg-gb-orange text-gb-bg border-gb-orange'
|
||||
: 'border-gb-bg-t text-gb-fg-f hover:border-gb-fg-t'
|
||||
}`}
|
||||
>
|
||||
{opt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
function targetKey(type: TargetType, id: string) {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState(channelName);
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const updateChannel = useChannelStore((s) => s.updateChannel);
|
||||
function draftFromOverride(o: ChannelOverride): DraftOverride {
|
||||
return {
|
||||
target_type: o.target_type,
|
||||
target_id: o.target_id,
|
||||
allow: o.allow_bitflags,
|
||||
deny: o.deny_bitflags,
|
||||
};
|
||||
}
|
||||
|
||||
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
|
||||
function emptyDraft(type: TargetType, id: string): DraftOverride {
|
||||
return { target_type: type, target_id: id, allow: 0, deny: 0 };
|
||||
}
|
||||
|
||||
function isDirty(saved: ChannelOverride | undefined, draft: DraftOverride): boolean {
|
||||
if (!saved) return draft.allow !== 0 || draft.deny !== 0;
|
||||
return saved.allow_bitflags !== draft.allow || saved.deny_bitflags !== draft.deny;
|
||||
}
|
||||
|
||||
function triState(allow: number, deny: number, flag: number): TriState {
|
||||
if ((deny & flag) === flag) return 'deny';
|
||||
if ((allow & flag) === flag) return 'allow';
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
function applyTri(draft: DraftOverride, flag: number, state: TriState): DraftOverride {
|
||||
let allow = draft.allow & ~flag;
|
||||
let deny = draft.deny & ~flag;
|
||||
if (state === 'allow') allow |= flag;
|
||||
if (state === 'deny') deny |= flag;
|
||||
return { ...draft, allow, deny };
|
||||
}
|
||||
|
||||
function displayName(member: Member): string {
|
||||
return member.nickname || member.display_name || member.username;
|
||||
}
|
||||
|
||||
export function ChannelSettingsModal({
|
||||
serverId,
|
||||
channelId,
|
||||
channelName,
|
||||
onClose,
|
||||
}: ChannelSettingsModalProps) {
|
||||
const [name, setName] = useState(channelName);
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<'general' | 'permissions'>('general');
|
||||
|
||||
const overridesByChannel = usePermissionStore((s) => s.overridesByChannel);
|
||||
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
||||
const setOverride = usePermissionStore((s) => s.setOverride);
|
||||
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
||||
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const allRoles = useRoleStore((s) => s.roles);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const members = useMemberStore((s) => s.membersByServer[serverId] || []);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
||||
|
||||
const overrides = overridesByChannel[channelId] || [];
|
||||
const roles = useMemo(
|
||||
() =>
|
||||
allRoles
|
||||
.filter((r) => r.server_id === serverId)
|
||||
.slice()
|
||||
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name)),
|
||||
[allRoles, serverId],
|
||||
);
|
||||
const members = useMemo(
|
||||
() => (membersByServer[serverId] || []).filter((m) => !m.is_bot),
|
||||
[membersByServer, serverId],
|
||||
);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftOverride | null>(null);
|
||||
const [savingPerms, setSavingPerms] = useState(false);
|
||||
const [permError, setPermError] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addTab, setAddTab] = useState<'role' | 'user'>('role');
|
||||
const [addQuery, setAddQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverrides(channelId).catch(() => setError('Failed to load overrides'));
|
||||
fetchRoles(serverId).catch(() => {});
|
||||
fetchMembers(serverId).catch(() => {});
|
||||
void fetchOverrides(channelId);
|
||||
void fetchRoles(serverId);
|
||||
void fetchMembers(serverId);
|
||||
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (addOpen) {
|
||||
setAddOpen(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
}, [onClose, addOpen]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.targetId) {
|
||||
setError('Select a target');
|
||||
// Auto-select first override when list loads / selection invalid.
|
||||
useEffect(() => {
|
||||
if (tab !== 'permissions') return;
|
||||
if (overrides.length === 0) {
|
||||
if (selectedKey && !draft) {
|
||||
// local-only draft without saved row is fine
|
||||
return;
|
||||
}
|
||||
if (!selectedKey) {
|
||||
setSelectedKey(null);
|
||||
setDraft(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const stillThere =
|
||||
selectedKey &&
|
||||
(overrides.some((o) => targetKey(o.target_type, o.target_id) === selectedKey) ||
|
||||
(draft && targetKey(draft.target_type, draft.target_id) === selectedKey));
|
||||
if (!stillThere) {
|
||||
const first = overrides[0];
|
||||
setSelectedKey(targetKey(first.target_type, first.target_id));
|
||||
setDraft(draftFromOverride(first));
|
||||
}
|
||||
}, [overrides, selectedKey, draft, tab]);
|
||||
|
||||
const savedForDraft = draft
|
||||
? overrides.find(
|
||||
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||
)
|
||||
: undefined;
|
||||
const dirty = draft ? isDirty(savedForDraft, draft) : false;
|
||||
|
||||
const resolveLabel = (type: TargetType, id: string): { label: string; color?: string; kind: string } => {
|
||||
if (type === 'role') {
|
||||
const role = roles.find((r) => r.id === id);
|
||||
return {
|
||||
label: role?.name ?? id.slice(0, 8),
|
||||
color: role?.color || '#ebdbb2',
|
||||
kind: role?.is_default ? 'everyone' : 'role',
|
||||
};
|
||||
}
|
||||
const m = members.find((x) => x.id === id);
|
||||
return {
|
||||
label: m ? displayName(m) : id.slice(0, 8),
|
||||
kind: 'member',
|
||||
};
|
||||
};
|
||||
|
||||
const selectTarget = (type: TargetType, id: string, fromOverride?: ChannelOverride) => {
|
||||
if (dirty && !window.confirm('Discard unsaved permission changes?')) return;
|
||||
setSelectedKey(targetKey(type, id));
|
||||
setDraft(fromOverride ? draftFromOverride(fromOverride) : emptyDraft(type, id));
|
||||
setPermError(null);
|
||||
setAddOpen(false);
|
||||
};
|
||||
|
||||
const existingKeys = useMemo(() => {
|
||||
const set = new Set(overrides.map((o) => targetKey(o.target_type, o.target_id)));
|
||||
if (draft) set.add(targetKey(draft.target_type, draft.target_id));
|
||||
return set;
|
||||
}, [overrides, draft]);
|
||||
|
||||
const availableRoles = roles.filter((r) => !existingKeys.has(targetKey('role', r.id)));
|
||||
const availableMembers = members.filter((m) => !existingKeys.has(targetKey('user', m.id)));
|
||||
|
||||
const filteredRoles = availableRoles.filter((r) =>
|
||||
r.name.toLowerCase().includes(addQuery.toLowerCase()),
|
||||
);
|
||||
const filteredMembers = availableMembers.filter((m) => {
|
||||
const q = addQuery.toLowerCase();
|
||||
return (
|
||||
m.username.toLowerCase().includes(q) ||
|
||||
displayName(m).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const handleAddRole = (role: Role) => {
|
||||
selectTarget('role', role.id);
|
||||
};
|
||||
|
||||
const handleAddMember = (member: Member) => {
|
||||
selectTarget('user', member.id);
|
||||
};
|
||||
|
||||
const handleSavePerms = async () => {
|
||||
if (!draft) return;
|
||||
setSavingPerms(true);
|
||||
setPermError(null);
|
||||
try {
|
||||
await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
|
||||
setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||
await setOverride(channelId, draft.target_type, draft.target_id, draft.allow, draft.deny);
|
||||
// Re-sync draft from store after save.
|
||||
const list = usePermissionStore.getState().overridesByChannel[channelId] || [];
|
||||
const saved = list.find(
|
||||
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||
);
|
||||
if (saved) setDraft(draftFromOverride(saved));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save override');
|
||||
setPermError(err instanceof Error ? err.message : 'Failed to save overrides');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingPerms(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
const name = editName.trim();
|
||||
if (!name || name === channelName) return;
|
||||
setSavingName(true);
|
||||
setError(null);
|
||||
const handleRemove = async () => {
|
||||
if (!draft) return;
|
||||
const { label } = resolveLabel(draft.target_type, draft.target_id);
|
||||
if (!window.confirm(`Remove permission overrides for ${label}?`)) return;
|
||||
setPermError(null);
|
||||
try {
|
||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
||||
`/servers/${serverId}/channels/${channelId}`,
|
||||
{ name }
|
||||
const exists = overrides.some(
|
||||
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||
);
|
||||
updateChannel(updated as any);
|
||||
if (exists) {
|
||||
await deleteOverride(channelId, draft.target_type, draft.target_id);
|
||||
}
|
||||
setSelectedKey(null);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename channel');
|
||||
setPermError(err instanceof Error ? err.message : 'Failed to remove override');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveName = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || name.trim() === channelName) return;
|
||||
setSavingName(true);
|
||||
setNameError(null);
|
||||
try {
|
||||
await api.patch(`/servers/${serverId}/channels/${channelId}`, { name: name.trim() });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setNameError(err instanceof Error ? err.message : 'Failed to update channel');
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const targets = form.targetType === 'role'
|
||||
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
|
||||
: members.map((m) => ({ id: m.id, label: m.username }));
|
||||
// Sidebar entries: all saved overrides + unsaved new draft not yet in list.
|
||||
const sidebarEntries = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
const items: { type: TargetType; id: string; override?: ChannelOverride }[] = [];
|
||||
for (const o of overrides) {
|
||||
const k = targetKey(o.target_type, o.target_id);
|
||||
keys.add(k);
|
||||
items.push({ type: o.target_type, id: o.target_id, override: o });
|
||||
}
|
||||
if (draft) {
|
||||
const k = targetKey(draft.target_type, draft.target_id);
|
||||
if (!keys.has(k)) {
|
||||
items.push({ type: draft.target_type, id: draft.target_id });
|
||||
}
|
||||
}
|
||||
// Roles first (everyone, then by name), then members.
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'role' ? -1 : 1;
|
||||
const la = resolveLabel(a.type, a.id).label.toLowerCase();
|
||||
const lb = resolveLabel(b.type, b.id).label.toLowerCase();
|
||||
return la.localeCompare(lb);
|
||||
});
|
||||
// resolveLabel depends on roles/members; include those
|
||||
}, [overrides, draft, roles, members]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-gb-bg border border-gb-bg-t w-[600px] max-h-[80vh] flex flex-col font-mono" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
<button type="button" onClick={onClose} className="text-xs text-gb-red">
|
||||
[x]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-gb-bg-t">
|
||||
<button onClick={() => setTab('overview')} className={`px-4 py-2 text-xs ${tab === 'overview' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[OVERVIEW]</button>
|
||||
<button onClick={() => setTab('permissions')} className={`px-4 py-2 text-xs ${tab === 'permissions' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[PERMISSIONS]</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('general')}
|
||||
className={`px-4 py-2 text-xs ${
|
||||
tab === 'general' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'
|
||||
}`}
|
||||
>
|
||||
[GENERAL]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('permissions')}
|
||||
className={`px-4 py-2 text-xs ${
|
||||
tab === 'permissions'
|
||||
? 'text-gb-orange bg-gb-bg-s'
|
||||
: 'text-gb-fg-f hover:text-gb-orange'
|
||||
}`}
|
||||
>
|
||||
[PERMISSIONS]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Channel name</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
{tab === 'general' && (
|
||||
<div className="p-4 overflow-y-auto">
|
||||
<form onSubmit={handleSaveName} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gb-fg-f text-xs mb-1">CHANNEL NAME</label>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); }}
|
||||
className="terminal-input text-sm flex-1"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="terminal-input w-full"
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{nameError && <p className="text-gb-red text-xs">ERR: {nameError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={savingName || !editName.trim() || editName.trim() === channelName}
|
||||
className="px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
||||
type="submit"
|
||||
disabled={savingName || !name.trim() || name.trim() === channelName}
|
||||
className="terminal-button disabled:opacity-50"
|
||||
>
|
||||
{savingName ? 'SAVING...' : '[SAVE]'}
|
||||
{savingName ? '[SAVING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-red">
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-gb-red">ERR: {error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'permissions' && (
|
||||
<div className="flex flex-1 min-h-[55vh] max-h-[70vh]">
|
||||
{/* Left: targets */}
|
||||
<div className="w-52 md:w-60 shrink-0 border-r border-gb-bg-t flex flex-col bg-gb-bg-s">
|
||||
<div className="p-2 border-b border-gb-bg-t space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddOpen(true);
|
||||
setAddQuery('');
|
||||
setAddTab('role');
|
||||
}}
|
||||
className="w-full terminal-button text-xs"
|
||||
>
|
||||
[+ ADD ROLE / MEMBER]
|
||||
</button>
|
||||
<p className="text-[10px] text-gb-fg-f px-0.5">
|
||||
tip: deny View for @everyone, allow a role for private channels
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sidebarEntries.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[no overrides yet]</p>
|
||||
)}
|
||||
{sidebarEntries.map((entry) => {
|
||||
const k = targetKey(entry.type, entry.id);
|
||||
const active = k === selectedKey;
|
||||
const { label, color, kind } = resolveLabel(entry.type, entry.id);
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => selectTarget(entry.type, entry.id, entry.override)}
|
||||
className={`w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 ${
|
||||
active ? 'bg-gb-bg text-gb-fg' : 'text-gb-fg-f hover:bg-gb-bg hover:text-gb-fg'
|
||||
}`}
|
||||
>
|
||||
{entry.type === 'role' ? (
|
||||
<span
|
||||
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gb-aqua shrink-0">@</span>
|
||||
)}
|
||||
<span className="truncate" style={entry.type === 'role' && active ? { color } : undefined}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">
|
||||
{kind === 'everyone' ? '@' : entry.type === 'role' ? 'R' : 'U'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'permissions' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Existing overrides</div>
|
||||
{overrides.length === 0 && <div className="text-xs text-gb-fg-f">[none]</div>}
|
||||
{overrides.map((o: ChannelOverride) => (
|
||||
<div key={o.id} className="flex items-center justify-between border border-gb-bg-t p-2 text-xs">
|
||||
<span className="text-gb-fg">{o.target_type}:{o.target_id}</span>
|
||||
<div className="flex gap-2 text-gb-fg-f">
|
||||
<span>allow:{o.allow_bitflags}</span>
|
||||
<span>deny:{o.deny_bitflags}</span>
|
||||
<button onClick={() => deleteOverride(channelId, o.target_type, o.target_id)} className="text-gb-red hover:text-gb-orange">[x]</button>
|
||||
|
||||
{/* Right: tri-state editor */}
|
||||
<div className="flex-1 min-w-0 flex flex-col relative">
|
||||
{!draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||
[add a role or member to set channel permissions]
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{permError && <p className="text-gb-red text-xs">ERR: {permError}</p>}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gb-fg-f">editing:</span>
|
||||
<span className="text-gb-aqua">
|
||||
{resolveLabel(draft.target_type, draft.target_id).label}
|
||||
</span>
|
||||
<span className="text-gb-fg-f">
|
||||
({draft.target_type === 'role' ? 'role' : 'member'})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gb-fg-f">
|
||||
✓ allow · ✗ deny · – inherit from roles
|
||||
</p>
|
||||
|
||||
{CHANNEL_PERM_CATEGORIES.map((cat) => (
|
||||
<div key={cat.name} className="border border-gb-bg-t">
|
||||
<div className="px-3 py-1.5 bg-gb-bg-s text-gb-aqua text-xs border-b border-gb-bg-t">
|
||||
[{cat.name.toUpperCase()}]
|
||||
</div>
|
||||
<div className="divide-y divide-gb-bg-t">
|
||||
{cat.keys.map((key) => {
|
||||
const flag = PERMS[key];
|
||||
const state = triState(draft.allow, draft.deny, flag);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="text-gb-fg truncate">{PERMISSION_LABELS[key]}</span>
|
||||
<div className="flex shrink-0 border border-gb-bg-t">
|
||||
<button
|
||||
type="button"
|
||||
title="Deny"
|
||||
onClick={() => setDraft(applyTri(draft, flag, 'deny'))}
|
||||
className={`px-2 py-0.5 ${
|
||||
state === 'deny'
|
||||
? 'bg-gb-red text-gb-bg'
|
||||
: 'text-gb-fg-f hover:text-gb-red'
|
||||
}`}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Inherit"
|
||||
onClick={() => setDraft(applyTri(draft, flag, 'inherit'))}
|
||||
className={`px-2 py-0.5 border-x border-gb-bg-t ${
|
||||
state === 'inherit'
|
||||
? 'bg-gb-bg-s text-gb-fg'
|
||||
: 'text-gb-fg-f hover:text-gb-fg'
|
||||
}`}
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Allow"
|
||||
onClick={() => setDraft(applyTri(draft, flag, 'allow'))}
|
||||
className={`px-2 py-0.5 ${
|
||||
state === 'allow'
|
||||
? 'bg-gb-green text-gb-bg'
|
||||
: 'text-gb-fg-f hover:text-gb-green'
|
||||
}`}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-gb-bg-t px-4 py-3 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSavePerms()}
|
||||
disabled={savingPerms || !dirty}
|
||||
className="terminal-button text-xs disabled:opacity-40"
|
||||
>
|
||||
{savingPerms ? '[SAVING...]' : '[SAVE CHANGES]'}
|
||||
</button>
|
||||
{dirty && <span className="text-gb-orange text-[10px]">unsaved</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||
>
|
||||
[REMOVE]
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add role/member picker overlay */}
|
||||
{addOpen && (
|
||||
<div className="absolute inset-0 bg-gb-bg/95 z-10 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gb-bg-t">
|
||||
<span className="text-xs text-gb-orange">ADD TO CHANNEL</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(false)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-red"
|
||||
>
|
||||
[close]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex border-b border-gb-bg-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddTab('role')}
|
||||
className={`px-3 py-1.5 text-xs ${
|
||||
addTab === 'role' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||
}`}
|
||||
>
|
||||
[ROLES / GROUPS]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddTab('user')}
|
||||
className={`px-3 py-1.5 text-xs ${
|
||||
addTab === 'user' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||
}`}
|
||||
>
|
||||
[MEMBERS]
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 border-b border-gb-bg-t">
|
||||
<input
|
||||
type="text"
|
||||
value={addQuery}
|
||||
onChange={(e) => setAddQuery(e.target.value)}
|
||||
placeholder={addTab === 'role' ? 'filter roles...' : 'filter members...'}
|
||||
className="terminal-input w-full text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{addTab === 'role' && filteredRoles.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[no roles left to add]</p>
|
||||
)}
|
||||
{addTab === 'role' &&
|
||||
filteredRoles.map((role) => (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
onClick={() => handleAddRole(role)}
|
||||
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||
/>
|
||||
<span className="truncate">{role.name}</span>
|
||||
{role.is_default && (
|
||||
<span className="ml-auto text-[10px] text-gb-fg-f">@everyone</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{addTab === 'user' && filteredMembers.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[no members left to add]</p>
|
||||
)}
|
||||
{addTab === 'user' &&
|
||||
filteredMembers.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleAddMember(m)}
|
||||
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||
>
|
||||
<span className="text-gb-aqua">@</span>
|
||||
<span className="truncate">{displayName(m)}</span>
|
||||
<span className="ml-auto text-[10px] text-gb-fg-f truncate max-w-[40%]">
|
||||
{m.username}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t border-gb-bg-t pt-3">
|
||||
<div className="text-xs text-gb-fg-f mb-2">New override</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={form.targetType}
|
||||
onChange={(e) => setForm({ ...form, targetType: e.target.value as TargetMode, targetId: '' })}
|
||||
className="terminal-input text-xs"
|
||||
>
|
||||
<option value="role">role</option>
|
||||
<option value="user">user</option>
|
||||
</select>
|
||||
<select
|
||||
value={form.targetId}
|
||||
onChange={(e) => setForm({ ...form, targetId: e.target.value })}
|
||||
className="terminal-input text-xs flex-1"
|
||||
>
|
||||
<option value="">-- select --</option>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<OverrideMatrix allow={form.allow} deny={form.deny} onChange={(a, d) => setForm({ ...form, allow: a, deny: d })} />
|
||||
{error && <div className="text-xs text-gb-red mt-2">ERR: {error}</div>}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.targetId}
|
||||
className="mt-2 px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'SAVING...' : '[SAVE OVERRIDE]'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NotificationPrompt } from './NotificationPrompt.tsx';
|
||||
import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
function statusColor(status: UserStatus): string {
|
||||
@@ -134,6 +135,9 @@ export function Layout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Link to="/settings" className="terminal-button text-xs px-1.5 py-0.5 hidden sm:inline-flex">[SETTINGS]</Link>
|
||||
{activeServerId && (
|
||||
<button onClick={() => setShowServerSettings(true)}
|
||||
|
||||
+322
-328
@@ -1,77 +1,13 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState } 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||
import {
|
||||
PERMS,
|
||||
PERM_CATEGORIES,
|
||||
PERMISSION_LABELS,
|
||||
hasPermission,
|
||||
type PermissionKey,
|
||||
} from '../stores/permissions.ts';
|
||||
|
||||
const GRUVBOX_COLORS = [
|
||||
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
||||
@@ -79,207 +15,167 @@ const GRUVBOX_COLORS = [
|
||||
'#a89984', '#928374', '#282828',
|
||||
];
|
||||
|
||||
function hasPerm(permissions: number, flag: number): boolean {
|
||||
return (permissions & flag) !== 0;
|
||||
// ponytail: full bitfield when admin is on; not every future flag needs a checkbox.
|
||||
const ALL_PERMS = Object.values(PERMS).reduce((a, b) => a | b, 0);
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
permissions: number;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
function draftFromRole(role: Role): Draft {
|
||||
return {
|
||||
name: role.name,
|
||||
color: role.color || '#ebdbb2',
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
};
|
||||
}
|
||||
|
||||
function isDirty(role: Role, draft: Draft): boolean {
|
||||
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>
|
||||
draft.name !== role.name ||
|
||||
draft.color !== (role.color || '#ebdbb2') ||
|
||||
draft.position !== role.position ||
|
||||
draft.permissions !== role.permissions
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) {
|
||||
const { serverId: paramServerId } = useParams<{ serverId: string }>();
|
||||
const serverId = propServerId || paramServerId;
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const embedded = Boolean(propServerId);
|
||||
|
||||
const allRoles = useRoleStore((s) => s.roles);
|
||||
const loading = useRoleStore((s) => s.loading);
|
||||
const error = useRoleStore((s) => s.error);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const createRole = useRoleStore((s) => s.createRole);
|
||||
const updateRole = useRoleStore((s) => s.updateRole);
|
||||
const deleteRole = useRoleStore((s) => s.deleteRole);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const roles = useMemo(
|
||||
() =>
|
||||
allRoles
|
||||
.filter((r) => !serverId || r.server_id === serverId)
|
||||
.slice()
|
||||
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name)),
|
||||
[allRoles, serverId],
|
||||
);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId) fetchRoles(serverId);
|
||||
if (serverId) void fetchRoles(serverId);
|
||||
}, [serverId, fetchRoles]);
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (!serverId) return;
|
||||
if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
||||
// Keep selection valid when role list changes.
|
||||
useEffect(() => {
|
||||
if (roles.length === 0) {
|
||||
setSelectedId(null);
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
const stillThere = selectedId && roles.some((r) => r.id === selectedId);
|
||||
if (!stillThere) {
|
||||
const next = roles[0];
|
||||
setSelectedId(next.id);
|
||||
setDraft(draftFromRole(next));
|
||||
}
|
||||
}, [roles, selectedId]);
|
||||
|
||||
const selected = roles.find((r) => r.id === selectedId) ?? null;
|
||||
const dirty = selected && draft ? isDirty(selected, draft) : false;
|
||||
const isAdmin = draft ? hasPermission(draft.permissions, PERMS.ADMINISTRATOR) : false;
|
||||
|
||||
const selectRole = (role: Role) => {
|
||||
if (dirty && !window.confirm('Discard unsaved changes?')) return;
|
||||
setSelectedId(role.id);
|
||||
setDraft(draftFromRole(role));
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const togglePerm = (flag: number) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, permissions: prev.permissions ^ flag };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAdmin = () => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (hasPermission(prev.permissions, PERMS.ADMINISTRATOR)) {
|
||||
return { ...prev, permissions: 0 };
|
||||
}
|
||||
return { ...prev, permissions: ALL_PERMS };
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!serverId || creating) return;
|
||||
if (dirty && !window.confirm('Discard unsaved changes?')) return;
|
||||
setCreating(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
await deleteRole(serverId, role.id);
|
||||
} catch {
|
||||
// error in store
|
||||
const top = roles[0]?.position ?? 0;
|
||||
const role = await createRole(serverId, {
|
||||
name: 'new-role',
|
||||
color: '#ebdbb2',
|
||||
permissions: PERMS.VIEW_CHANNEL | PERMS.SEND_MESSAGES,
|
||||
position: top + 1,
|
||||
});
|
||||
setSelectedId(role.id);
|
||||
setDraft(draftFromRole(role));
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to create role');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!serverId || !selected || !draft) return;
|
||||
const name = draft.name.trim();
|
||||
if (!name) {
|
||||
setLocalError('Name required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
await updateRole(serverId, selected.id, {
|
||||
name: selected.is_default ? selected.name : name,
|
||||
color: draft.color,
|
||||
permissions: draft.permissions,
|
||||
position: selected.is_default ? selected.position : draft.position,
|
||||
});
|
||||
// Store updates selected role; re-sync draft from next render via roles.
|
||||
const updated = useRoleStore.getState().roles.find((r) => r.id === selected.id);
|
||||
if (updated) setDraft(draftFromRole(updated));
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to save role');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!serverId || !selected || selected.is_default) return;
|
||||
if (!window.confirm(`Delete role "${selected.name}"? This cannot be undone.`)) return;
|
||||
setLocalError(null);
|
||||
try {
|
||||
await deleteRole(serverId, selected.id);
|
||||
setSelectedId(null);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,97 +188,195 @@ export function RoleManager({ serverId: propServerId }: { serverId?: string } =
|
||||
}
|
||||
|
||||
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>
|
||||
<div className={`bg-gb-bg font-mono flex flex-col ${embedded ? 'h-full min-h-[55vh]' : 'h-full w-full'}`}>
|
||||
{!embedded && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
<span className="text-sm text-gb-orange">ROLE MANAGER</span>
|
||||
<Link to="/" className="text-xs text-gb-fg-f hover:text-gb-aqua">
|
||||
{'<'} [BACK]
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-1 min-h-0 border border-gb-bg-t">
|
||||
{/* Left: role list */}
|
||||
<div className="w-48 md:w-56 shrink-0 border-r border-gb-bg-t flex flex-col bg-gb-bg-s">
|
||||
<div className="p-2 border-b border-gb-bg-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
||||
className="terminal-button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating}
|
||||
className="w-full terminal-button text-xs disabled:opacity-50"
|
||||
>
|
||||
[CREATE ROLE]
|
||||
{creating ? '[...]' : '[+ 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">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[loading...]</p>
|
||||
)}
|
||||
{!loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[no roles]</p>
|
||||
)}
|
||||
{roles.map((role) => {
|
||||
const active = role.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
onClick={() => selectRole(role)}
|
||||
className={`w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 ${
|
||||
active ? 'bg-gb-bg text-gb-fg' : 'text-gb-fg-f hover:bg-gb-bg hover:text-gb-fg'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
||||
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||
/>
|
||||
<span
|
||||
className="font-mono text-sm font-bold truncate"
|
||||
style={{ color: role.color || '#ebdbb2' }}
|
||||
>
|
||||
<span className="truncate" style={{ color: active ? (role.color || undefined) : undefined }}>
|
||||
{role.name}
|
||||
</span>
|
||||
{role.is_default && (
|
||||
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
||||
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">@</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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<RoleForm
|
||||
serverId={serverId}
|
||||
role={editingRole ?? undefined}
|
||||
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
||||
/>
|
||||
)}
|
||||
{/* Right: live editor */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{!selected || !draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||
[select a role]
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{(error || localError) && (
|
||||
<p className="text-gb-red text-xs">ERR: {localError || error}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f text-xs mb-1">NAME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.name}
|
||||
disabled={selected.is_default}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value.slice(0, 64) })}
|
||||
maxLength={64}
|
||||
className="terminal-input w-full text-sm disabled:opacity-50"
|
||||
/>
|
||||
{selected.is_default && (
|
||||
<p className="text-gb-fg-f text-[10px] mt-1">@everyone name is fixed</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f text-xs mb-1">COLOR</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{GRUVBOX_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, color: c })}
|
||||
className={`w-6 h-6 border-2 ${
|
||||
draft.color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.color}
|
||||
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||
className="terminal-input w-full text-xs"
|
||||
placeholder="#ebdbb2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!selected.is_default && (
|
||||
<div>
|
||||
<label className="block text-gb-fg-f text-xs mb-1">POSITION</label>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.position}
|
||||
onChange={(e) => setDraft({ ...draft, position: parseInt(e.target.value, 10) || 0 })}
|
||||
className="terminal-input w-24 text-xs"
|
||||
/>
|
||||
<p className="text-gb-fg-f text-[10px] mt-1">higher = listed first</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-gb-fg-f text-xs mb-2">PERMISSIONS</label>
|
||||
<div className="border border-gb-bg-t p-3 mb-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAdmin}
|
||||
onChange={toggleAdmin}
|
||||
className="accent-gb-red"
|
||||
/>
|
||||
<span className="text-gb-red">Administrator (all permissions)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isAdmin &&
|
||||
PERM_CATEGORIES.map((cat) => (
|
||||
<div key={cat.name} className="border border-gb-bg-t p-3 mb-2">
|
||||
<p className="text-gb-aqua text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
||||
<div className="space-y-1">
|
||||
{cat.keys.map((key: PermissionKey) => {
|
||||
const flag = PERMS[key];
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 cursor-pointer text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasPermission(draft.permissions, flag)}
|
||||
onChange={() => togglePerm(flag)}
|
||||
className="accent-gb-orange"
|
||||
/>
|
||||
<span className="text-gb-fg">{PERMISSION_LABELS[key]}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-gb-bg-t px-4 py-3 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !dirty}
|
||||
className="terminal-button text-xs disabled:opacity-40"
|
||||
>
|
||||
{saving ? '[SAVING...]' : '[SAVE CHANGES]'}
|
||||
</button>
|
||||
{dirty && <span className="text-gb-orange text-[10px]">unsaved</span>}
|
||||
{!selected.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete()}
|
||||
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||
>
|
||||
[DELETE ROLE]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t w-[750px] max-h-[85vh] flex flex-col font-mono"
|
||||
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
@@ -342,7 +342,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
||||
)}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<div className="h-[60vh] overflow-y-auto">
|
||||
<div className="h-[60vh] -m-4">
|
||||
<RoleManager serverId={serverId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,36 +1,67 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
// ponytail: top IDE palettes as CSS data-theme tokens; gruvbox stays default
|
||||
export const THEMES = [
|
||||
{ id: 'gruvbox', label: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light', label: 'Gruvbox Light' },
|
||||
{ id: 'one-dark', label: 'One Dark' },
|
||||
{ id: 'dracula', label: 'Dracula' },
|
||||
{ id: 'nord', label: 'Nord' },
|
||||
{ id: 'tokyo-night', label: 'Tokyo Night' },
|
||||
{ id: 'catppuccin', label: 'Catppuccin' },
|
||||
{ id: 'solarized-dark', label: 'Solarized Dark' },
|
||||
{ id: 'monokai', label: 'Monokai' },
|
||||
{ id: 'github-dark', label: 'GitHub Dark' },
|
||||
] as const;
|
||||
|
||||
export type ThemeId = (typeof THEMES)[number]['id'];
|
||||
|
||||
const THEME_IDS = new Set<string>(THEMES.map((t) => t.id));
|
||||
|
||||
export function normalizeTheme(raw: string | null): ThemeId {
|
||||
if (!raw) return 'gruvbox';
|
||||
// migrate old light/dark toggle
|
||||
if (raw === 'light') return 'gruvbox-light';
|
||||
if (raw === 'dark') return 'gruvbox';
|
||||
if (THEME_IDS.has(raw)) return raw as ThemeId;
|
||||
return 'gruvbox';
|
||||
}
|
||||
|
||||
export function applyTheme(theme: ThemeId) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-theme', theme);
|
||||
root.classList.remove('light', 'dark');
|
||||
if (theme === 'gruvbox-light') root.classList.add('light');
|
||||
else root.classList.add('dark');
|
||||
localStorage.setItem('dumpster-theme', theme);
|
||||
|
||||
const bg = getComputedStyle(root).getPropertyValue('--gb-bg').trim() || '#282828';
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', bg);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('dumpster-theme');
|
||||
return (stored === 'light' ? 'light' : 'dark');
|
||||
});
|
||||
const [theme, setTheme] = useState<ThemeId>(() =>
|
||||
normalizeTheme(localStorage.getItem('dumpster-theme')),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.classList.add('light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.classList.add('dark');
|
||||
root.classList.remove('light');
|
||||
}
|
||||
localStorage.setItem('dumpster-theme', theme);
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||
className="max-w-[9.5rem] px-1 py-0.5 text-[10px] md:text-xs font-mono text-gb-fg-f bg-gb-bg-s border border-gb-bg-t hover:text-gb-orange focus:text-gb-orange cursor-pointer"
|
||||
title="Theme"
|
||||
aria-label="Color theme"
|
||||
>
|
||||
[{theme === 'dark' ? 'light' : 'dark'}]
|
||||
</button>
|
||||
{THEMES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { usePushStore } from '../stores/push.ts';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
|
||||
export function UserSettings() {
|
||||
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
||||
@@ -316,6 +317,17 @@ export function UserSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||
THEME:
|
||||
</label>
|
||||
<ThemeToggle />
|
||||
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
||||
Default is Gruvbox. Choice is saved in this browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Push Notifications */}
|
||||
{isSupported && (
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
|
||||
@@ -54,6 +54,37 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
MENTION_EVERYONE: 'Mention @everyone',
|
||||
};
|
||||
|
||||
/** Grouped for role/channel permission UIs. ADMINISTRATOR is special-cased in editors. */
|
||||
export const PERM_CATEGORIES: { name: string; keys: PermissionKey[] }[] = [
|
||||
{
|
||||
name: 'General',
|
||||
keys: [
|
||||
'VIEW_CHANNEL',
|
||||
'SEND_MESSAGES',
|
||||
'MANAGE_MESSAGES',
|
||||
'ADD_REACTIONS',
|
||||
'EMBED_LINKS',
|
||||
'ATTACH_FILES',
|
||||
'MENTION_EVERYONE',
|
||||
'USE_EXTERNAL_EMOJIS',
|
||||
'CREATE_INSTANT_INVITE',
|
||||
'CHANGE_NICKNAME',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Moderation',
|
||||
keys: ['KICK_MEMBERS', 'BAN_MEMBERS', 'MUTE_MEMBERS', 'MANAGE_NICKNAMES'],
|
||||
},
|
||||
{
|
||||
name: 'Server',
|
||||
keys: ['MANAGE_SERVER', 'MANAGE_CHANNELS', 'MANAGE_ROLES', 'MANAGE_WEBHOOKS'],
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
keys: ['CONNECT_VOICE', 'SPEAK_VOICE', 'SHARE_SCREEN'],
|
||||
},
|
||||
];
|
||||
|
||||
export function hasPermission(perms: number, flag: number): boolean {
|
||||
// Match backend permissions.Has: all required bits must be present.
|
||||
return (perms & flag) === flag;
|
||||
|
||||
+183
-5
@@ -2,8 +2,10 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme token sets — applied via html[data-theme="..."]. Default = gruvbox. */
|
||||
@layer base {
|
||||
:root {
|
||||
:root,
|
||||
[data-theme="gruvbox"] {
|
||||
--gb-bg-h: #1d2021;
|
||||
--gb-bg: #282828;
|
||||
--gb-bg-s: #3c3836;
|
||||
@@ -24,8 +26,7 @@
|
||||
--gb-border: #665c54;
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
.light {
|
||||
[data-theme="gruvbox-light"] {
|
||||
--gb-bg-h: #f9f5d7;
|
||||
--gb-bg: #fbf1c7;
|
||||
--gb-bg-s: #ebdbb2;
|
||||
@@ -35,9 +36,187 @@
|
||||
--gb-fg-s: #3c3836;
|
||||
--gb-fg-t: #504945;
|
||||
--gb-fg-f: #7c6f64;
|
||||
--gb-red: #9d0006;
|
||||
--gb-green: #79740e;
|
||||
--gb-yellow: #b57614;
|
||||
--gb-blue: #076678;
|
||||
--gb-purple: #8f3f71;
|
||||
--gb-aqua: #427b58;
|
||||
--gb-orange: #af3a03;
|
||||
--gb-gray: #928374;
|
||||
--gb-border: #a89984;
|
||||
}
|
||||
|
||||
/* Atom / VS Code One Dark Pro */
|
||||
[data-theme="one-dark"] {
|
||||
--gb-bg-h: #1e2127;
|
||||
--gb-bg: #282c34;
|
||||
--gb-bg-s: #21252b;
|
||||
--gb-bg-t: #3e4451;
|
||||
--gb-bg-f: #2c313a;
|
||||
--gb-fg: #abb2bf;
|
||||
--gb-fg-s: #9da5b4;
|
||||
--gb-fg-t: #828997;
|
||||
--gb-fg-f: #5c6370;
|
||||
--gb-red: #e06c75;
|
||||
--gb-green: #98c379;
|
||||
--gb-yellow: #e5c07b;
|
||||
--gb-blue: #61afef;
|
||||
--gb-purple: #c678dd;
|
||||
--gb-aqua: #56b6c2;
|
||||
--gb-orange: #d19a66;
|
||||
--gb-gray: #5c6370;
|
||||
--gb-border: #3e4451;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--gb-bg-h: #191a21;
|
||||
--gb-bg: #282a36;
|
||||
--gb-bg-s: #21222c;
|
||||
--gb-bg-t: #44475a;
|
||||
--gb-bg-f: #343746;
|
||||
--gb-fg: #f8f8f2;
|
||||
--gb-fg-s: #e2e2dc;
|
||||
--gb-fg-t: #cfcfc2;
|
||||
--gb-fg-f: #6272a4;
|
||||
--gb-red: #ff5555;
|
||||
--gb-green: #50fa7b;
|
||||
--gb-yellow: #f1fa8c;
|
||||
--gb-blue: #8be9fd;
|
||||
--gb-purple: #bd93f9;
|
||||
--gb-aqua: #8be9fd;
|
||||
--gb-orange: #ffb86c;
|
||||
--gb-gray: #6272a4;
|
||||
--gb-border: #44475a;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--gb-bg-h: #242933;
|
||||
--gb-bg: #2e3440;
|
||||
--gb-bg-s: #3b4252;
|
||||
--gb-bg-t: #434c5e;
|
||||
--gb-bg-f: #3b4252;
|
||||
--gb-fg: #d8dee9;
|
||||
--gb-fg-s: #e5e9f0;
|
||||
--gb-fg-t: #eceff4;
|
||||
--gb-fg-f: #4c566a;
|
||||
--gb-red: #bf616a;
|
||||
--gb-green: #a3be8c;
|
||||
--gb-yellow: #ebcb8b;
|
||||
--gb-blue: #81a1c1;
|
||||
--gb-purple: #b48ead;
|
||||
--gb-aqua: #88c0d0;
|
||||
--gb-orange: #d08770;
|
||||
--gb-gray: #616e88;
|
||||
--gb-border: #4c566a;
|
||||
}
|
||||
|
||||
[data-theme="tokyo-night"] {
|
||||
--gb-bg-h: #0f0f14;
|
||||
--gb-bg: #1a1b26;
|
||||
--gb-bg-s: #16161e;
|
||||
--gb-bg-t: #292e42;
|
||||
--gb-bg-f: #1f2335;
|
||||
--gb-fg: #c0caf5;
|
||||
--gb-fg-s: #a9b1d6;
|
||||
--gb-fg-t: #9aa5ce;
|
||||
--gb-fg-f: #565f89;
|
||||
--gb-red: #f7768e;
|
||||
--gb-green: #9ece6a;
|
||||
--gb-yellow: #e0af68;
|
||||
--gb-blue: #7aa2f7;
|
||||
--gb-purple: #bb9af7;
|
||||
--gb-aqua: #7dcfff;
|
||||
--gb-orange: #ff9e64;
|
||||
--gb-gray: #565f89;
|
||||
--gb-border: #292e42;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin"] {
|
||||
--gb-bg-h: #11111b;
|
||||
--gb-bg: #1e1e2e;
|
||||
--gb-bg-s: #181825;
|
||||
--gb-bg-t: #313244;
|
||||
--gb-bg-f: #24273a;
|
||||
--gb-fg: #cdd6f4;
|
||||
--gb-fg-s: #bac2de;
|
||||
--gb-fg-t: #a6adc8;
|
||||
--gb-fg-f: #6c7086;
|
||||
--gb-red: #f38ba8;
|
||||
--gb-green: #a6e3a1;
|
||||
--gb-yellow: #f9e2af;
|
||||
--gb-blue: #89b4fa;
|
||||
--gb-purple: #cba6f7;
|
||||
--gb-aqua: #94e2d5;
|
||||
--gb-orange: #fab387;
|
||||
--gb-gray: #6c7086;
|
||||
--gb-border: #313244;
|
||||
}
|
||||
|
||||
[data-theme="solarized-dark"] {
|
||||
--gb-bg-h: #001f27;
|
||||
--gb-bg: #002b36;
|
||||
--gb-bg-s: #073642;
|
||||
--gb-bg-t: #094959;
|
||||
--gb-bg-f: #073642;
|
||||
--gb-fg: #839496;
|
||||
--gb-fg-s: #93a1a1;
|
||||
--gb-fg-t: #eee8d5;
|
||||
--gb-fg-f: #657b83;
|
||||
--gb-red: #dc322f;
|
||||
--gb-green: #859900;
|
||||
--gb-yellow: #b58900;
|
||||
--gb-blue: #268bd2;
|
||||
--gb-purple: #d33682;
|
||||
--gb-aqua: #2aa198;
|
||||
--gb-orange: #cb4b16;
|
||||
--gb-gray: #586e75;
|
||||
--gb-border: #586e75;
|
||||
}
|
||||
|
||||
[data-theme="monokai"] {
|
||||
--gb-bg-h: #1a1b16;
|
||||
--gb-bg: #272822;
|
||||
--gb-bg-s: #1e1f1c;
|
||||
--gb-bg-t: #3e3d32;
|
||||
--gb-bg-f: #2d2e27;
|
||||
--gb-fg: #f8f8f2;
|
||||
--gb-fg-s: #e6e6e0;
|
||||
--gb-fg-t: #cfcfc2;
|
||||
--gb-fg-f: #75715e;
|
||||
--gb-red: #f92672;
|
||||
--gb-green: #a6e22e;
|
||||
--gb-yellow: #e6db74;
|
||||
--gb-blue: #66d9ef;
|
||||
--gb-purple: #ae81ff;
|
||||
--gb-aqua: #a1efe4;
|
||||
--gb-orange: #fd971f;
|
||||
--gb-gray: #75715e;
|
||||
--gb-border: #49483e;
|
||||
}
|
||||
|
||||
/* VS Code / GitHub Dark default-ish */
|
||||
[data-theme="github-dark"] {
|
||||
--gb-bg-h: #010409;
|
||||
--gb-bg: #0d1117;
|
||||
--gb-bg-s: #161b22;
|
||||
--gb-bg-t: #30363d;
|
||||
--gb-bg-f: #21262d;
|
||||
--gb-fg: #e6edf3;
|
||||
--gb-fg-s: #c9d1d9;
|
||||
--gb-fg-t: #8b949e;
|
||||
--gb-fg-f: #6e7681;
|
||||
--gb-red: #f85149;
|
||||
--gb-green: #3fb950;
|
||||
--gb-yellow: #d29922;
|
||||
--gb-blue: #58a6ff;
|
||||
--gb-purple: #bc8cff;
|
||||
--gb-aqua: #39c5cf;
|
||||
--gb-orange: #db6d28;
|
||||
--gb-gray: #8b949e;
|
||||
--gb-border: #30363d;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
||||
font-size: 14px;
|
||||
@@ -50,7 +229,6 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Thin themed scrollbar for chat area */
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -98,7 +276,7 @@
|
||||
}
|
||||
|
||||
.terminal-mention {
|
||||
background-color: rgba(142, 192, 124, 0.12);
|
||||
background-color: color-mix(in srgb, var(--gb-aqua) 12%, transparent);
|
||||
color: var(--gb-aqua);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-27
@@ -8,33 +8,25 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'gb-bg-h': '#1d2021',
|
||||
'gb-bg': '#282828',
|
||||
'gb-bg-s': '#3c3836',
|
||||
'gb-bg-t': '#504945',
|
||||
'gb-bg-f': '#32302f',
|
||||
'gb-fg': '#ebdbb2',
|
||||
'gb-fg-s': '#d5c4a1',
|
||||
'gb-fg-t': '#bdae93',
|
||||
'gb-fg-f': '#a89984',
|
||||
'gb-red': '#fb4934',
|
||||
'gb-green': '#b8bb26',
|
||||
'gb-yellow': '#fabd2f',
|
||||
'gb-blue': '#83a598',
|
||||
'gb-purple': '#d3869b',
|
||||
'gb-aqua': '#8ec07c',
|
||||
'gb-orange': '#fe8019',
|
||||
'gb-gray': '#928374',
|
||||
// Light mode overrides
|
||||
'gb-light-bg-h': '#f9f5d7',
|
||||
'gb-light-bg': '#fbf1c7',
|
||||
'gb-light-bg-s': '#ebdbb2',
|
||||
'gb-light-bg-t': '#d5c4a1',
|
||||
'gb-light-bg-f': '#bdae93',
|
||||
'gb-light-fg': '#282828',
|
||||
'gb-light-fg-s': '#3c3836',
|
||||
'gb-light-fg-t': '#504945',
|
||||
'gb-light-fg-f': '#7c6f64',
|
||||
// ponytail: CSS vars so data-theme palettes apply without rewriting components
|
||||
'gb-bg-h': 'var(--gb-bg-h)',
|
||||
'gb-bg': 'var(--gb-bg)',
|
||||
'gb-bg-s': 'var(--gb-bg-s)',
|
||||
'gb-bg-t': 'var(--gb-bg-t)',
|
||||
'gb-bg-f': 'var(--gb-bg-f)',
|
||||
'gb-fg': 'var(--gb-fg)',
|
||||
'gb-fg-s': 'var(--gb-fg-s)',
|
||||
'gb-fg-t': 'var(--gb-fg-t)',
|
||||
'gb-fg-f': 'var(--gb-fg-f)',
|
||||
'gb-red': 'var(--gb-red)',
|
||||
'gb-green': 'var(--gb-green)',
|
||||
'gb-yellow': 'var(--gb-yellow)',
|
||||
'gb-blue': 'var(--gb-blue)',
|
||||
'gb-purple': 'var(--gb-purple)',
|
||||
'gb-aqua': 'var(--gb-aqua)',
|
||||
'gb-orange': 'var(--gb-orange)',
|
||||
'gb-gray': 'var(--gb-gray)',
|
||||
'gb-border': 'var(--gb-border)',
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
||||
|
||||
Reference in New Issue
Block a user