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:
2026-07-15 21:46:07 -04:00
parent 3c7b8278ce
commit 0f19b9089d
10 changed files with 1230 additions and 556 deletions
+29 -2
View File
@@ -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 -->
+572 -167
View File
@@ -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>
);
+4
View File
@@ -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
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
)}
+56 -25
View File
@@ -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>
);
}
+12
View File
@@ -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">
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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'],