From 0f19b9089dbc1ade955f19bf5350835930d5d089 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 15 Jul 2026 21:46:07 -0400 Subject: [PATCH] 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. --- web/index.html | 31 +- web/src/components/ChannelSettingsModal.tsx | 739 +++++++++++++++----- web/src/components/Layout.tsx | 4 + web/src/components/RoleManager.tsx | 650 +++++++++-------- web/src/components/ServerSettingsModal.tsx | 4 +- web/src/components/ThemeToggle.tsx | 81 ++- web/src/components/UserSettings.tsx | 12 + web/src/stores/permissions.ts | 31 + web/src/styles/index.css | 188 ++++- web/tailwind.config.js | 46 +- 10 files changed, 1230 insertions(+), 556 deletions(-) diff --git a/web/index.html b/web/index.html index 19f2456..1ff59d7 100644 --- a/web/index.html +++ b/web/index.html @@ -1,13 +1,40 @@ - + + - + diff --git a/web/src/components/ChannelSettingsModal.tsx b/web/src/components/ChannelSettingsModal.tsx index 5beb412..b6f085e 100644 --- a/web/src/components/ChannelSettingsModal.tsx +++ b/web/src/components/ChannelSettingsModal.tsx @@ -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([ + '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 ( -
- {(Object.keys(PERMISSION_LABELS) as (keyof typeof PERMISSION_LABELS)[]).map((key) => { - const flag = PERMS[key]; - const s = state(flag); - return ( -
- {PERMISSION_LABELS[key]} -
- {(['inherit', 'allow', 'deny'] as const).map((opt) => ( - - ))} -
-
- ); - })} -
- ); +function targetKey(type: TargetType, id: string) { + return `${type}:${id}`; } -export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) { - const [tab, setTab] = useState('overview'); - const [form, setForm] = useState({ targetType: 'role', targetId: '', allow: 0, deny: 0 }); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(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(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(null); + const [draft, setDraft] = useState(null); + const [savingPerms, setSavingPerms] = useState(false); + const [permError, setPermError] = useState(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(); + 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 ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()} + >
CHANNEL SETTINGS: #{channelName} - +
+
- - + +
-
- {tab === 'overview' && ( -
-
Channel name
-
+ + {tab === 'general' && ( +
+
+
+ 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 /> +
+ {nameError &&

ERR: {nameError}

} +
+
- {error &&
ERR: {error}
} +
+
+ )} + + {tab === 'permissions' && ( +
+ {/* Left: targets */} +
+
+ +

+ tip: deny View for @everyone, allow a role for private channels +

+
+
+ {sidebarEntries.length === 0 && ( +

[no overrides yet]

+ )} + {sidebarEntries.map((entry) => { + const k = targetKey(entry.type, entry.id); + const active = k === selectedKey; + const { label, color, kind } = resolveLabel(entry.type, entry.id); + return ( + + ); + })} +
- )} - {tab === 'permissions' && ( -
-
Existing overrides
- {overrides.length === 0 &&
[none]
} - {overrides.map((o: ChannelOverride) => ( -
- {o.target_type}:{o.target_id} -
- allow:{o.allow_bitflags} - deny:{o.deny_bitflags} - + + {/* Right: tri-state editor */} +
+ {!draft ? ( +
+ [add a role or member to set channel permissions] +
+ ) : ( + <> +
+ {permError &&

ERR: {permError}

} +
+ editing: + + {resolveLabel(draft.target_type, draft.target_id).label} + + + ({draft.target_type === 'role' ? 'role' : 'member'}) + +
+

+ ✓ allow · ✗ deny · – inherit from roles +

+ + {CHANNEL_PERM_CATEGORIES.map((cat) => ( +
+
+ [{cat.name.toUpperCase()}] +
+
+ {cat.keys.map((key) => { + const flag = PERMS[key]; + const state = triState(draft.allow, draft.deny, flag); + return ( +
+ {PERMISSION_LABELS[key]} +
+ + + +
+
+ ); + })} +
+
+ ))} +
+ +
+ + {dirty && unsaved} + +
+ + )} + + {/* Add role/member picker overlay */} + {addOpen && ( +
+
+ ADD TO CHANNEL + +
+
+ + +
+
+ setAddQuery(e.target.value)} + placeholder={addTab === 'role' ? 'filter roles...' : 'filter members...'} + className="terminal-input w-full text-xs" + autoFocus + /> +
+
+ {addTab === 'role' && filteredRoles.length === 0 && ( +

[no roles left to add]

+ )} + {addTab === 'role' && + filteredRoles.map((role) => ( + + ))} + {addTab === 'user' && filteredMembers.length === 0 && ( +

[no members left to add]

+ )} + {addTab === 'user' && + filteredMembers.map((m) => ( + + ))}
- ))} - -
-
New override
-
- - -
- setForm({ ...form, allow: a, deny: d })} /> - {error &&
ERR: {error}
} - -
+ )}
- )} -
+
+ )}
); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index a719602..9be51ae 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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() {
)}
+
+ +
[SETTINGS] {activeServerId && (
- setColor(e.target.value)} - className="terminal-input w-full text-xs" - placeholder="#ebdbb2" - /> -
- -
- - setPosition(parseInt(e.target.value) || 0)} - className="terminal-input w-24" - /> -
- -
- -
- {/* Administrator all-or-nothing */} -
- -
- - {/* Category groups */} - {!hasPerm(permissions, PERMS.ADMINISTRATOR) && - PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => ( -
-

[{cat.name.toUpperCase()}]

-
- {cat.perms - .filter((p) => p.key !== 'ADMINISTRATOR') - .map((p) => ( - - ))} -
-
- ))} -
-
- -
- - -
- -
-
+ 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(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(null); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [creating, setCreating] = useState(false); + const [localError, setLocalError] = useState(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 ( -
-
-
-
-            {'┌──────────────────────────────────┐\n'}
-            {'│      === ROLE MANAGER ===        │\n'}
-            {'└──────────────────────────────────┘'}
-          
+
+ {!embedded && ( +
+ ROLE MANAGER + + {'<'} [BACK] + +
+ )} - {error && ( -

ERR: {error}

- )} - -
+
+ {/* Left: role list */} +
+
- - {loading && roles.length === 0 && ( -

[loading roles...]

- )} - {!loading && roles.length === 0 && ( -

[no roles configured]

- )} - -
- {roles.map((role) => ( -
-
+
+ {loading && roles.length === 0 && ( +

[loading...]

+ )} + {!loading && roles.length === 0 && ( +

[no roles]

+ )} + {roles.map((role) => { + const active = role.id === selectedId; + return ( +
-

- perms: {permSummary(role.permissions)} -

-
- - {!role.is_default && ( - - )} -
-
- ))} -
- -
- - {'<'} [BACK TO CHAT] - + + ); + })}
-
- {showForm && ( - { setShowForm(false); setEditingRole(null); }} - /> - )} + {/* Right: live editor */} +
+ {!selected || !draft ? ( +
+ [select a role] +
+ ) : ( + <> +
+ {(error || localError) && ( +

ERR: {localError || error}

+ )} + +
+ + setDraft({ ...draft, name: e.target.value.slice(0, 64) })} + maxLength={64} + className="terminal-input w-full text-sm disabled:opacity-50" + /> + {selected.is_default && ( +

@everyone name is fixed

+ )} +
+ +
+ +
+ {GRUVBOX_COLORS.map((c) => ( +
+ setDraft({ ...draft, color: e.target.value })} + className="terminal-input w-full text-xs" + placeholder="#ebdbb2" + /> +
+ + {!selected.is_default && ( +
+ + setDraft({ ...draft, position: parseInt(e.target.value, 10) || 0 })} + className="terminal-input w-24 text-xs" + /> +

higher = listed first

+
+ )} + +
+ +
+ +
+ + {!isAdmin && + PERM_CATEGORIES.map((cat) => ( +
+

[{cat.name.toUpperCase()}]

+
+ {cat.keys.map((key: PermissionKey) => { + const flag = PERMS[key]; + return ( + + ); + })} +
+
+ ))} +
+
+ +
+ + {dirty && unsaved} + {!selected.is_default && ( + + )} +
+ + )} +
+
); } diff --git a/web/src/components/ServerSettingsModal.tsx b/web/src/components/ServerSettingsModal.tsx index 2ea83ec..ff26760 100644 --- a/web/src/components/ServerSettingsModal.tsx +++ b/web/src/components/ServerSettingsModal.tsx @@ -210,7 +210,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr onClick={handleClose} >
e.stopPropagation()} >
@@ -342,7 +342,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr )} {tab === 'roles' && ( -
+
)} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 5659b27..dfda45c 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -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(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(() => { - const stored = localStorage.getItem('dumpster-theme'); - return (stored === 'light' ? 'light' : 'dark'); - }); + const [theme, setTheme] = useState(() => + 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 ( - + {THEMES.map((t) => ( + + ))} + ); } diff --git a/web/src/components/UserSettings.tsx b/web/src/components/UserSettings.tsx index c44be34..cd1b304 100644 --- a/web/src/components/UserSettings.tsx +++ b/web/src/components/UserSettings.tsx @@ -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() {
+ {/* Appearance */} +
+ + +

+ Default is Gruvbox. Choice is saved in this browser. +

+
+ {/* Push Notifications */} {isSupported && (
diff --git a/web/src/stores/permissions.ts b/web/src/stores/permissions.ts index 75c917d..1dfcc6b 100644 --- a/web/src/stores/permissions.ts +++ b/web/src/stores/permissions.ts @@ -54,6 +54,37 @@ export const PERMISSION_LABELS: Record = { 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; diff --git a/web/src/styles/index.css b/web/src/styles/index.css index a8141e6..fc04e14 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -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); } } diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 9feca98..e23c5df 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -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'],