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
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:
878
packages/shared/src/components/settings/ServerSettingsModal.tsx
Normal file
878
packages/shared/src/components/settings/ServerSettingsModal.tsx
Normal 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user