/** * ServerSettingsModal — full-screen settings overlay (same pattern as * UserSettingsModal) with Overview / Roles / Emojis tabs, wired to the * Convex backend. * * Opens via the `brycord:open-server-settings` window event dispatched * from GuildHeaderDropdown / GuildNavbar. All three tabs are inlined * here so the file is self-contained — the sibling *Tab.tsx files * still hold the old Matrix-based code and are not imported. */ import { useMutation, useQuery } from 'convex/react'; import { ClockCounterClockwise, Gear, Plus, Prohibit, ShieldStar, Smiley, Trash, UploadSimple, X, } from '@phosphor-icons/react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { api } from '../../../../../convex/_generated/api'; import type { Id } from '../../../../../convex/_generated/dataModel'; import { useIsMobile } from '../../hooks/useIsMobile'; import { CustomEmojisTab } from './CustomEmojisTab'; import { MobileServerSettings } from './MobileServerSettings'; import { useRolesView } from './RolesView'; import userStyles from './UserSettingsModal.module.css'; export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit'; interface ServerSettingsModalProps { isOpen: boolean; onClose: () => void; initialTab?: ServerSettingsTab; } const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> = [ { id: 'overview', label: 'Overview', icon: Gear }, { id: 'roles', label: 'Roles', icon: ShieldStar }, { id: 'emojis', label: 'Custom Emoji', icon: Smiley }, { id: 'bans', label: 'Bans', icon: Prohibit }, { id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise }, ]; export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) { // Desktop opens straight to the Overview tab when no explicit // tab is requested — the two-column layout always needs a // selection to fill the content pane. Mobile uses the category // list as the root and only jumps into a panel if the caller // actually asked for one, so we keep the raw `initialTab` below // to forward to `MobileServerSettings`. const resolvedInitialTab: ServerSettingsTab = initialTab ?? 'overview'; const [activeTab, setActiveTab] = useState( resolvedInitialTab, ); const isMobile = useIsMobile(); useEffect(() => { if (isOpen) setActiveTab(resolvedInitialTab); }, [isOpen, resolvedInitialTab]); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [isOpen, onClose]); // Roles view takes over the whole settings surface — its own // sidebar (back + create + role list) and its own main column // (role editor). We still delegate the back button to flipping // activeTab back to 'overview' so the outer modal stays open. // // IMPORTANT: this hook must run before any conditional early return // (mobile shortcut below, `!isOpen` guard) so the hook count stays // stable across renders when the viewport crosses the mobile // breakpoint. React error #300 if this moves back under `isMobile`. const rolesView = useRolesView({ onBack: () => setActiveTab('overview') }); // Mobile gets the full-screen overlay with a category list ↔ panel // flow, desktop gets the two-column modal below. Pass the raw // `initialTab` (NOT the resolved version) so mobile lands on the // category list when nothing was requested. if (isMobile) { return ( ); } if (!isOpen) return null; const active = TABS.find((t) => t.id === activeTab) ?? TABS[0]; const inRolesView = activeTab === 'roles'; return createPortal(
e.stopPropagation()}>
{inRolesView && rolesView.header ? ( // Dirty roles editor takes over the header with a // Reset / Save Changes bar. The close X is hidden // until the user either saves or resets their drafts. rolesView.header ) : ( <>

{inRolesView ? 'Roles & Permissions' : active.label}

)}
{activeTab === 'overview' && } {inRolesView && rolesView.content} {activeTab === 'emojis' && } {activeTab === 'bans' && } {activeTab === 'audit' && }
, document.body, ); } /* ------------------------------------------------------------------- */ /* Overview */ /* ------------------------------------------------------------------- */ export function OverviewTab() { const userId = typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null; const settings = useQuery(api.serverSettings.get, {}) as | { serverName?: string; iconUrl?: string | null; afkChannelId?: Id<'channels'> | null; afkTimeout?: number; } | null | undefined; const channels = useQuery(api.channels.list, {}) ?? []; const updateSettings = useMutation(api.serverSettings.update); const voiceChannels = useMemo( () => channels.filter((c: any) => c.type === 'voice'), [channels], ); const [afkChannelId, setAfkChannelId] = useState(''); const [afkTimeout, setAfkTimeout] = useState(300); const [status, setStatus] = useState<{ type: 'ok' | 'err'; message: string } | null>(null); const [saving, setSaving] = useState(false); useEffect(() => { if (settings) { setAfkChannelId((settings.afkChannelId as string) ?? ''); setAfkTimeout(settings.afkTimeout ?? 300); } }, [settings?.afkChannelId, settings?.afkTimeout]); const handleSave = async () => { if (!userId) { setStatus({ type: 'err', message: 'You must be logged in.' }); return; } if (afkTimeout < 60 || afkTimeout > 3600) { setStatus({ type: 'err', message: 'AFK timeout must be between 60 and 3600 seconds.' }); return; } setSaving(true); setStatus(null); try { await updateSettings({ userId, afkChannelId: (afkChannelId || undefined) as Id<'channels'> | undefined, afkTimeout, }); setStatus({ type: 'ok', message: 'Saved.' }); } catch (err: any) { setStatus({ type: 'err', message: err?.message ?? 'Failed to save.' }); } finally { setSaving(false); } }; const serverName = settings?.serverName ?? 'Server'; const iconUrl = settings?.iconUrl ?? null; const initials = serverName .split(/\s+/) .map((w) => w[0]) .join('') .slice(0, 2) .toUpperCase(); return ( <>

Overview

Manage your server's display info and idle settings.

{iconUrl ? ( {serverName} ) : (
{initials || 'S'}
)}
setAfkTimeout(Number(e.target.value))} style={inputStyle} />
{status && ( {status.message} )}
); } /* ------------------------------------------------------------------- */ /* Roles */ /* ------------------------------------------------------------------- */ const PERMISSION_KEYS = [ 'manage_channels', 'manage_roles', 'manage_messages', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members', 'manage_nicknames', 'ban_members', ] as const; type PermissionKey = (typeof PERMISSION_KEYS)[number]; interface RoleDoc { _id: Id<'roles'>; name: string; color: string; position?: number; permissions?: Record; isHoist?: boolean; } export function RolesTab() { // Owner is a bootstrap-only, permanently frozen role — hide it // from the editable roles list so admins can't delete, rename, // or strip its permissions. Backend mutations enforce the same // rule server-side as a second line of defence. const roles = ( (useQuery(api.roles.list, {}) ?? []) as RoleDoc[] ).filter((r) => r.name !== 'Owner'); const createRole = useMutation(api.roles.create); const updateRole = useMutation(api.roles.update); const removeRole = useMutation(api.roles.remove); const [selectedId, setSelectedId] = useState | null>(null); const [error, setError] = useState(null); const selected = useMemo( () => roles.find((r) => r._id === selectedId) ?? null, [roles, selectedId], ); // Local draft state for the editor — seeded when selection changes. const [draftName, setDraftName] = useState(''); const [draftColor, setDraftColor] = useState('#99aab5'); const [draftPerms, setDraftPerms] = useState>({}); const [draftHoist, setDraftHoist] = useState(false); useEffect(() => { if (selected) { setDraftName(selected.name); setDraftColor(selected.color || '#99aab5'); setDraftPerms({ ...(selected.permissions ?? {}) }); setDraftHoist(!!selected.isHoist); } }, [selectedId]); const handleCreate = async () => { setError(null); try { const created: any = await createRole({ name: 'new role', color: '#99aab5', permissions: {}, isHoist: false, position: 0, }); if (created?._id) setSelectedId(created._id); } catch (err: any) { setError(err?.message ?? 'Failed to create role'); } }; const handleSave = async () => { if (!selected) return; setError(null); try { await updateRole({ id: selected._id, name: draftName, color: draftColor, permissions: draftPerms, isHoist: draftHoist, }); } catch (err: any) { setError(err?.message ?? 'Failed to save role'); } }; const handleDelete = async () => { if (!selected) return; if (!confirm(`Delete role "${selected.name}"?`)) return; setError(null); try { await removeRole({ id: selected._id }); setSelectedId(null); } catch (err: any) { setError(err?.message ?? 'Failed to delete role'); } }; return ( <>

Roles

Use roles to group your members and assign permissions.

{error && (
{error}
)}
{roles.length === 0 && (
No roles yet.
)} {roles.map((role) => ( ))}
{!selected ? (
Select a role to edit, or create a new one.
) : (
setDraftName(e.target.value)} style={inputStyle} />
setDraftColor(e.target.value)} style={{ width: 48, height: 40, border: 'none', borderRadius: 6, background: 'transparent', cursor: 'pointer', }} /> setDraftColor(e.target.value)} style={{ ...inputStyle, maxWidth: 140 }} />
{PERMISSION_KEYS.map((key) => ( ))}
)}
); } /* ------------------------------------------------------------------- */ /* Emojis */ /* ------------------------------------------------------------------- */ /** * Back-compat shim — the old desktop EmojisTab lived inline in this * file and is still imported by MobileServerSettings. It now just * renders the shared `CustomEmojisTab` so both surfaces get the new * Fluxer-style layout without a second code path. */ export function EmojisTab() { return ; } // eslint-disable-next-line @typescript-eslint/no-unused-vars function _LegacyEmojisTabDeadCode(): any { const userId = typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null; const emojis = (useQuery(api.customEmojis.list, {}) ?? []) as CustomEmojiDoc[]; const generateUploadUrl = useMutation(api.files.generateUploadUrl); const uploadEmoji = useMutation(api.customEmojis.upload); const removeEmoji = useMutation(api.customEmojis.remove); const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [status, setStatus] = useState<{ type: 'ok' | 'err'; message: string } | null>(null); const handlePickFile = () => fileInputRef.current?.click(); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (fileInputRef.current) fileInputRef.current.value = ''; if (!file) return; if (!userId) { setStatus({ type: 'err', message: 'You must be logged in.' }); return; } // Prompt for an emoji name based on the filename. const defaultName = file.name .replace(/\.[^.]+$/, '') .replace(/[^a-zA-Z0-9_]/g, '_') .slice(0, 32) || 'emoji'; const name = prompt('Emoji name (letters, numbers, underscores; 2-32 chars):', defaultName); if (!name) return; setUploading(true); setStatus(null); try { const uploadUrl = await generateUploadUrl({}); const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': file.type }, body: file, }); if (!res.ok) throw new Error('Upload failed'); const { storageId } = (await res.json()) as { storageId: Id<'_storage'> }; await uploadEmoji({ userId, name, storageId }); setStatus({ type: 'ok', message: `Added :${name}:` }); } catch (err: any) { setStatus({ type: 'err', message: err?.message ?? 'Upload failed' }); } finally { setUploading(false); } }; const handleRemove = async (emojiId: Id<'customEmojis'>) => { if (!userId) return; if (!confirm('Delete this emoji?')) return; setStatus(null); try { await removeEmoji({ userId, emojiId }); } catch (err: any) { setStatus({ type: 'err', message: err?.message ?? 'Failed to remove' }); } }; return ( <>

Emojis

Upload custom emojis for this server. PNG, GIF, or WebP up to ~500 KB works best.

{emojis.length} {emojis.length === 1 ? 'emoji' : 'emojis'} {status && ( {status.message} )}
{emojis.length === 0 ? (
No custom emojis yet. Upload one to get started.
) : (
{emojis.map((emoji) => (
{emoji.name} :{emoji.name}:
))}
)} ); } /* ------------------------------------------------------------------- */ /* Shared bits */ /* ------------------------------------------------------------------- */ function Label({ children }: { children: React.ReactNode }) { return ( ); } const inputStyle: React.CSSProperties = { width: '100%', padding: '10px 12px', background: 'var(--background-tertiary)', border: '1px solid var(--background-modifier-accent)', borderRadius: 6, color: 'var(--text-primary)', fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', }; const primaryBtnStyle: React.CSSProperties = { background: 'var(--brand-primary)', color: '#fff', border: 'none', padding: '10px 18px', borderRadius: 6, cursor: 'pointer', fontWeight: 600, fontSize: 14, }; const dangerBtnStyle: React.CSSProperties = { background: 'var(--status-danger, #da373c)', color: '#fff', border: 'none', padding: '10px 18px', borderRadius: 6, cursor: 'pointer', fontWeight: 600, fontSize: 14, }; /* ------------------------------------------------------------------- */ /* Bans */ /* ------------------------------------------------------------------- */ function formatRelative(ts: number): string { const diff = Date.now() - ts; const s = Math.max(0, Math.floor(diff / 1000)); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 30) return `${d}d ago`; return new Date(ts).toLocaleDateString(); } export function BansTab() { const myUserId = typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null; const bans = useQuery(api.bans.list, myUserId ? { actorId: myUserId } : 'skip'); const allUsers = useQuery(api.auth.getPublicKeys, {}) ?? []; const banMutation = useMutation(api.bans.ban); const unbanMutation = useMutation(api.bans.unban); const [pickerOpen, setPickerOpen] = useState(false); const [pickedUserId, setPickedUserId] = useState(''); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const bannedIdSet = useMemo( () => new Set((bans ?? []).map((b: any) => b.userId)), [bans], ); const bannableUsers = useMemo( () => (allUsers as any[]).filter( (u) => u.id !== myUserId && !bannedIdSet.has(u.id), ), [allUsers, bannedIdSet, myUserId], ); const handleBan = async () => { if (!myUserId || !pickedUserId) return; setBusy(true); setError(null); try { await banMutation({ actorId: myUserId, userId: pickedUserId as Id<'userProfiles'>, reason: reason.trim() || undefined, }); setPickerOpen(false); setPickedUserId(''); setReason(''); } catch (err: any) { setError(err?.message ?? 'Failed to ban user.'); } finally { setBusy(false); } }; const handleUnban = async (userId: string) => { if (!myUserId) return; try { await unbanMutation({ actorId: myUserId, userId: userId as Id<'userProfiles'>, }); } catch (err: any) { setError(err?.message ?? 'Failed to unban user.'); } }; return ( <>

Bans

Banned users can't log in or send messages. Unbanning restores access.

{error && (
{error}
)} {pickerOpen ? (
setReason(e.target.value)} maxLength={200} style={inputStyle} />
) : ( )} {bans === undefined ? (
Loading bans…
) : bans.length === 0 ? (
No one is banned.
) : (
{bans.map((b: any) => (
{b.user?.avatarUrl ? ( ) : (
{(b.user?.displayName || b.user?.username || '?').slice(0, 1).toUpperCase()}
)}
{b.user?.displayName || b.user?.username || 'Unknown user'}
{b.reason ? `“${b.reason}” · ` : ''} banned by {b.actor?.displayName || b.actor?.username || 'unknown'} · {formatRelative(b.createdAt)}
))}
)} ); } /* ------------------------------------------------------------------- */ /* Audit Log */ /* ------------------------------------------------------------------- */ const AUDIT_LABELS: Record = { 'channel.create': 'created channel', 'channel.delete': 'deleted channel', 'channel.rename': 'renamed channel', 'channel.update_topic': 'updated channel topic', 'role.create': 'created role', 'role.delete': 'deleted role', 'role.update': 'updated role', 'role.assign': 'assigned role', 'role.unassign': 'removed role', 'server.settings_update': 'updated server settings', 'ban.add': 'banned', 'ban.remove': 'unbanned', }; export function AuditLogTab() { const myUserId = typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null; const entries = useQuery( api.audit.list, myUserId ? { actorId: myUserId, limit: 200 } : 'skip', ); return ( <>

Audit Log

Recent admin actions, newest first.

{entries === undefined ? (
Loading…
) : entries.length === 0 ? (
Nothing logged yet.
) : (
{entries.map((e: any) => { const label = AUDIT_LABELS[e.action] ?? e.action; const actorName = e.actor?.displayName || e.actor?.username || 'Someone'; return (
{e.actor?.avatarUrl ? ( ) : (
{actorName.slice(0, 1).toUpperCase()}
)}
{actorName} {label} {e.targetName ? <> {e.targetName} : null}
{formatRelative(e.createdAt)}
); })}
)} ); }