Files
DiscordClone/packages/shared/src/components/settings/ServerSettingsModal.tsx
Bryan1029384756 593eaba82e 1.1.3
2026-04-18 15:41:51 -05:00

1269 lines
35 KiB
TypeScript

/**
* 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<ServerSettingsTab>(
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 (
<MobileServerSettings
isOpen={isOpen}
onClose={onClose}
initialTab={initialTab}
/>
);
}
if (!isOpen) return null;
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];
const inRolesView = activeTab === 'roles';
return createPortal(
<div className={userStyles.overlay} onClick={onClose}>
<div className={userStyles.modal} onClick={(e) => e.stopPropagation()}>
<nav className={userStyles.sidebar}>
{inRolesView ? (
rolesView.sidebar
) : (
<div className={userStyles.sidebarInner}>
<div className={userStyles.sidebarCategoryTitle}>Server Settings</div>
<div className={userStyles.sidebarGroup}>
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
className={`${userStyles.sidebarItem} ${isActive ? userStyles.sidebarItemActive : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className={userStyles.sidebarItemIcon}>
<Icon size={18} />
</span>
<span className={userStyles.sidebarItemLabel}>{tab.label}</span>
</button>
);
})}
</div>
</div>
)}
</nav>
<div className={userStyles.contentColumn}>
<div className={userStyles.contentHeader}>
{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
) : (
<>
<h1 className={userStyles.contentTitle}>
{inRolesView ? 'Roles & Permissions' : active.label}
</h1>
<button
type="button"
className={userStyles.closeButton}
onClick={onClose}
aria-label="Close settings"
>
<X size={22} weight="bold" />
</button>
</>
)}
</div>
<div className={userStyles.contentScroll}>
<div className={userStyles.contentInner}>
{activeTab === 'overview' && <OverviewTab />}
{inRolesView && rolesView.content}
{activeTab === 'emojis' && <CustomEmojisTab />}
{activeTab === 'bans' && <BansTab />}
{activeTab === 'audit' && <AuditLogTab />}
</div>
</div>
</div>
</div>
</div>,
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<string>('');
const [afkTimeout, setAfkTimeout] = useState<number>(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 (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Overview</h2>
<p className={userStyles.profileDescription}>
Manage your server's display info and idle settings.
</p>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 24 }}>
{iconUrl ? (
<img
src={iconUrl}
alt={serverName}
style={{
width: 96,
height: 96,
borderRadius: 24,
objectFit: 'cover',
background: 'var(--background-tertiary)',
}}
/>
) : (
<div
style={{
width: 96,
height: 96,
borderRadius: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
fontSize: 30,
fontWeight: 700,
}}
>
{initials || 'S'}
</div>
)}
<div style={{ flex: 1 }}>
<Label>Server Name</Label>
<input
type="text"
value={serverName}
readOnly
style={inputStyle}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div>
<Label>AFK / Idle Channel</Label>
<select
value={afkChannelId}
onChange={(e) => setAfkChannelId(e.target.value)}
style={inputStyle}
>
<option value="">No AFK Channel</option>
{voiceChannels.map((ch: any) => (
<option key={ch._id} value={ch._id}>
{ch.name}
</option>
))}
</select>
</div>
<div>
<Label>AFK Timeout (seconds)</Label>
<input
type="number"
min={60}
max={3600}
value={afkTimeout}
onChange={(e) => setAfkTimeout(Number(e.target.value))}
style={inputStyle}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button type="button" onClick={handleSave} disabled={saving} style={primaryBtnStyle}>
{saving ? 'Saving' : 'Save Changes'}
</button>
{status && (
<span
style={{
fontSize: 13,
color: status.type === 'err' ? '#f87171' : 'var(--text-secondary)',
}}
>
{status.message}
</span>
)}
</div>
</>
);
}
/* ------------------------------------------------------------------- */
/* 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<string, boolean>;
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<Id<'roles'> | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, boolean>>({});
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 (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Roles</h2>
<p className={userStyles.profileDescription}>
Use roles to group your members and assign permissions.
</p>
</div>
{error && (
<div
style={{
padding: '8px 12px',
marginBottom: 12,
background: 'rgba(234,80,80,0.15)',
border: '1px solid rgba(234,80,80,0.4)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 20, minHeight: 440 }}>
<div style={{ flex: '0 0 240px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<button type="button" onClick={handleCreate} style={{ ...primaryBtnStyle, width: '100%' }}>
<Plus size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
Create Role
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto' }}>
{roles.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: 8 }}>
No roles yet.
</div>
)}
{roles.map((role) => (
<button
key={role._id}
type="button"
onClick={() => setSelectedId(role._id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
background:
selectedId === role._id
? 'var(--background-modifier-hover)'
: 'var(--background-secondary)',
border:
selectedId === role._id
? '1px solid var(--brand-primary)'
: '1px solid transparent',
borderRadius: 6,
cursor: 'pointer',
textAlign: 'left',
color: 'var(--text-primary)',
font: 'inherit',
fontSize: 14,
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: '50%',
flexShrink: 0,
background: role.color || '#99aab5',
}}
/>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{role.name}
</span>
</button>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{!selected ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'var(--text-secondary)',
fontSize: 14,
}}
>
Select a role to edit, or create a new one.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Label>Role Name</Label>
<input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
style={inputStyle}
/>
</div>
<div>
<Label>Role Color</Label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={draftColor}
onChange={(e) => setDraftColor(e.target.value)}
style={{
width: 48,
height: 40,
border: 'none',
borderRadius: 6,
background: 'transparent',
cursor: 'pointer',
}}
/>
<input
type="text"
value={draftColor}
onChange={(e) => setDraftColor(e.target.value)}
style={{ ...inputStyle, maxWidth: 140 }}
/>
</div>
</div>
<div>
<Label>Display Options</Label>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 14,
color: 'var(--text-primary)',
}}
>
<input
type="checkbox"
checked={draftHoist}
onChange={(e) => setDraftHoist(e.target.checked)}
/>
Display role members separately from online members
</label>
</div>
<div>
<Label>Permissions</Label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{PERMISSION_KEYS.map((key) => (
<label
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--text-primary)',
padding: '6px 8px',
background: 'var(--background-tertiary)',
borderRadius: 6,
}}
>
<input
type="checkbox"
checked={!!draftPerms[key]}
onChange={(e) =>
setDraftPerms((p) => ({ ...p, [key]: e.target.checked }))
}
/>
{key.replace(/_/g, ' ')}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button type="button" onClick={handleSave} style={primaryBtnStyle}>
Save
</button>
<button type="button" onClick={handleDelete} style={dangerBtnStyle}>
<Trash size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
Delete
</button>
</div>
</div>
)}
</div>
</div>
</>
);
}
/* ------------------------------------------------------------------- */
/* 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 <CustomEmojisTab />;
}
// 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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Emojis</h2>
<p className={userStyles.profileDescription}>
Upload custom emojis for this server. PNG, GIF, or WebP up to ~500 KB works best.
</p>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 16 }}>
<button type="button" onClick={handlePickFile} disabled={uploading} style={primaryBtnStyle}>
<UploadSimple size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
{uploading ? 'Uploading…' : 'Upload Emoji'}
</button>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{emojis.length} {emojis.length === 1 ? 'emoji' : 'emojis'}
</span>
{status && (
<span
style={{
fontSize: 13,
color: status.type === 'err' ? '#f87171' : 'var(--text-secondary)',
}}
>
{status.message}
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/gif,image/webp,image/jpeg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{emojis.length === 0 ? (
<div
style={{
padding: '32px 20px',
textAlign: 'center',
fontSize: 13,
color: 'var(--text-secondary)',
background: 'var(--background-secondary)',
border: '1px dashed var(--background-modifier-accent)',
borderRadius: 8,
}}
>
No custom emojis yet. Upload one to get started.
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
gap: 10,
}}
>
{emojis.map((emoji) => (
<div
key={emoji._id}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
padding: '10px 8px',
background: 'var(--background-secondary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 8,
}}
>
<img
src={emoji.src}
alt={emoji.name}
style={{ width: 56, height: 56, objectFit: 'contain' }}
/>
<span
style={{
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
fontSize: 11,
color: 'var(--text-secondary)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
:{emoji.name}:
</span>
<button
type="button"
onClick={() => handleRemove(emoji._id)}
style={{
position: 'absolute',
top: 4,
right: 4,
width: 22,
height: 22,
border: 'none',
borderRadius: 4,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label={`Delete ${emoji.name}`}
>
<Trash size={12} weight="bold" />
</button>
</div>
))}
</div>
)}
</>
);
}
/* ------------------------------------------------------------------- */
/* Shared bits */
/* ------------------------------------------------------------------- */
function Label({ children }: { children: React.ReactNode }) {
return (
<label
style={{
display: 'block',
marginBottom: 6,
fontSize: 12,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'var(--text-secondary)',
}}
>
{children}
</label>
);
}
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<string>('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Bans</h2>
<p className={userStyles.profileDescription}>
Banned users can't log in or send messages. Unbanning restores access.
</p>
</div>
{error && (
<div
style={{
padding: 10,
marginBottom: 12,
borderRadius: 6,
background: 'rgba(248, 113, 113, 0.12)',
color: '#f87171',
fontSize: 13,
}}
>
{error}
</div>
)}
{pickerOpen ? (
<div
style={{
padding: 16,
marginBottom: 16,
border: '1px solid var(--background-modifier-accent)',
borderRadius: 8,
background: 'var(--background-secondary)',
}}
>
<Label>User</Label>
<select
value={pickedUserId}
onChange={(e) => setPickedUserId(e.target.value)}
style={inputStyle}
>
<option value="">Select a user…</option>
{bannableUsers.map((u: any) => (
<option key={u.id} value={u.id}>
{u.displayName || u.username}
</option>
))}
</select>
<div style={{ height: 12 }} />
<Label>Reason (optional)</Label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={200}
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
type="button"
onClick={handleBan}
disabled={busy || !pickedUserId}
style={dangerBtnStyle}
>
{busy ? 'Banning' : 'Ban user'}
</button>
<button
type="button"
onClick={() => {
setPickerOpen(false);
setPickedUserId('');
setReason('');
setError(null);
}}
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)' }}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ ...primaryBtnStyle, marginBottom: 16, display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Plus size={16} weight="bold" /> Ban a user
</button>
)}
{bans === undefined ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading bans…</div>
) : bans.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>No one is banned.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{bans.map((b: any) => (
<div
key={b._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 8,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
}}
>
{b.user?.avatarUrl ? (
<img
src={b.user.avatarUrl}
alt=""
style={{ width: 40, height: 40, borderRadius: '50%' }}
/>
) : (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
fontWeight: 700,
}}
>
{(b.user?.displayName || b.user?.username || '?').slice(0, 1).toUpperCase()}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
{b.user?.displayName || b.user?.username || 'Unknown user'}
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: 12,
marginTop: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{b.reason ? `“${b.reason}” · ` : ''}
banned by {b.actor?.displayName || b.actor?.username || 'unknown'} · {formatRelative(b.createdAt)}
</div>
</div>
<button
type="button"
onClick={() => handleUnban(b.userId)}
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)', color: 'var(--text-primary)' }}
>
Unban
</button>
</div>
))}
</div>
)}
</>
);
}
/* ------------------------------------------------------------------- */
/* Audit Log */
/* ------------------------------------------------------------------- */
const AUDIT_LABELS: Record<string, string> = {
'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 (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Audit Log</h2>
<p className={userStyles.profileDescription}>
Recent admin actions, newest first.
</p>
</div>
{entries === undefined ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading…</div>
) : entries.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
Nothing logged yet.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.map((e: any) => {
const label = AUDIT_LABELS[e.action] ?? e.action;
const actorName = e.actor?.displayName || e.actor?.username || 'Someone';
return (
<div
key={e._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 6,
background: 'var(--background-secondary)',
}}
>
{e.actor?.avatarUrl ? (
<img
src={e.actor.avatarUrl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%' }}
/>
) : (
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
fontWeight: 700,
fontSize: 12,
}}
>
{actorName.slice(0, 1).toUpperCase()}
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
color: 'var(--text-primary)',
fontSize: 13,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<strong>{actorName}</strong> {label}
{e.targetName ? <> <strong>{e.targetName}</strong></> : null}
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: 12,
flexShrink: 0,
}}
title={new Date(e.createdAt).toLocaleString()}
>
{formatRelative(e.createdAt)}
</div>
</div>
);
})}
</div>
)}
</>
);
}