/** * 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 { Gear, Plus, 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'; 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 }, ]; export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }: ServerSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); const isMobile = useIsMobile(); useEffect(() => { if (isOpen) setActiveTab(initialTab); }, [isOpen, initialTab]); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [isOpen, onClose]); // Mobile gets the full-screen overlay with a category list ↔ panel // flow, desktop gets the two-column modal below. if (isMobile) { return ( ); } // 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. const rolesView = useRolesView({ onBack: () => setActiveTab('overview') }); 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' && }
, 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', ] 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, };