feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s

- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
This commit is contained in:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -0,0 +1,878 @@
/**
* 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<ServerSettingsTab>(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 (
<MobileServerSettings
isOpen={isOpen}
onClose={onClose}
initialTab={initialTab}
/>
);
}
// 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(
<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 />}
</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',
] 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,
};