All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s
890 lines
25 KiB
TypeScript
890 lines
25 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 { 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 }: 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]);
|
|
|
|
// 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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
|