Add custom emojis
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s

This commit is contained in:
Bryan1029384756
2026-02-16 21:33:37 -06:00
parent 2b9fd4e7e0
commit b63c7a71e1
10 changed files with 663 additions and 73 deletions

View File

@@ -513,6 +513,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [mentionQuery, setMentionQuery] = useState(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
@@ -536,6 +537,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || [];
const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip");
const customEmojis = useQuery(api.customEmojis.list) || [];
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list,
@@ -765,6 +767,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setEditingMessage(null);
setMentionQuery(null);
setUnreadDividerTimestamp(null);
setReactionPickerMsgId(null);
onTogglePinned();
}, [channelId]);
@@ -1013,7 +1016,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (!match) return;
const name = match[1];
const emoji = AllEmojis.find(e => e.name === name);
const emoji = customEmojis.find(e => e.name === name) || AllEmojis.find(e => e.name === name);
if (!emoji) return;
const img = document.createElement('img');
@@ -1278,7 +1281,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
deleteMessageMutation({ id: msg.id, userId: currentUserId });
break;
case 'reaction':
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
setReactionPickerMsgId(msg.id);
break;
}
setContextMenu(null);
@@ -1382,6 +1385,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
isMentioned={isMentioned}
isOwner={isOwner}
roles={roles}
customEmojis={customEmojis}
isEditing={editingMessage?.id === msg.id}
isHovered={hoveredMessageId === msg.id}
editInput={editInput}
@@ -1389,7 +1393,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }}
@@ -1412,6 +1416,22 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
</div>
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{reactionPickerMsgId && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999 }} onClick={() => setReactionPickerMsgId(null)}>
<div style={{ position: 'absolute', right: '80px', top: '50%', transform: 'translateY(-50%)' }} onClick={(e) => e.stopPropagation()}>
<GifPicker
initialTab="Emoji"
onSelect={(data) => {
if (typeof data !== 'string' && data.name) {
addReaction({ messageId: reactionPickerMsgId, userId: currentUserId, emoji: data.name });
}
setReactionPickerMsgId(null);
}}
onClose={() => setReactionPickerMsgId(null)}
/>
</div>
</div>
)}
{inputContextMenu && <InputContextMenu x={inputContextMenu.x} y={inputContextMenu.y} onClose={() => setInputContextMenu(null)} onPaste={async () => {
try {
if (inputDivRef.current) inputDivRef.current.focus();

View File

@@ -1,6 +1,6 @@
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { useConvex, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const EmojiItem = ({ emoji, onSelect }) => (
@@ -93,11 +93,12 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
);
};
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory }) => {
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory, customEmojis = [] }) => {
if (search) {
const filtered = AllEmojis
.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
.slice(0, 100);
const q = search.toLowerCase().replace(/:/g, '');
const customFiltered = customEmojis.filter(e => e.name.toLowerCase().includes(q));
const builtinFiltered = AllEmojis.filter(e => e.name.toLowerCase().includes(q));
const filtered = [...customFiltered, ...builtinFiltered].slice(0, 100);
return (
<div className="emoji-grid" style={{ height: '100%' }}>
<div style={emojiGridStyle}>
@@ -109,51 +110,67 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
);
}
const CategoryHeader = ({ name, collapsed }) => (
<div
onClick={() => toggleCategory(name)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: '8px',
padding: '4px',
borderRadius: '4px'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="var(--header-secondary)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: 'var(--header-secondary)',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
{name}
</h3>
</div>
);
return (
<div className="emoji-grid" style={{ height: '100%' }}>
{customEmojis.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<CategoryHeader name="Custom" collapsed={collapsedCategories['Custom']} />
{!collapsedCategories['Custom'] && (
<div style={emojiGridStyle}>
{customEmojis.map((emoji) => (
<EmojiItem key={emoji._id || emoji.name} emoji={emoji} onSelect={onSelect} />
))}
</div>
)}
</div>
)}
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
<div
onClick={() => toggleCategory(category)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: '8px',
padding: '4px',
borderRadius: '4px'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="var(--header-secondary)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: 'var(--header-secondary)',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
{category}
</h3>
</div>
<CategoryHeader name={category} collapsed={collapsedCategories[category]} />
{!collapsedCategories[category] && (
<div style={emojiGridStyle}>
{emojis.map((emoji, idx) => (
@@ -181,6 +198,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
const inputRef = useRef(null);
const convex = useConvex();
const customEmojis = useQuery(api.customEmojis.list) || [];
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
const setActiveTab = (tab) => {
@@ -314,7 +332,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
) : activeTab === 'GIFs' ? (
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
) : (
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} />
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} customEmojis={customEmojis} />
)}
</div>
</div>

View File

@@ -60,9 +60,11 @@ export const formatMentions = (text, roles) => {
return result;
};
export const formatEmojis = (text) => {
export const formatEmojis = (text, customEmojis = []) => {
if (!text) return '';
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
const custom = customEmojis.find(e => e.name === name);
if (custom) return `![${match}](${custom.src})`;
const emoji = AllEmojis.find(e => e.name === name);
return emoji ? `![${match}](${emoji.src})` : match;
});
@@ -91,12 +93,17 @@ export const parseSystemMessage = (content) => {
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
const getReactionIcon = (name) => {
const getReactionIcon = (name, customEmojis = []) => {
const custom = customEmojis.find(e => e.name === name);
if (custom) return custom.src;
switch (name) {
case 'thumbsup': return thumbsupIcon;
case 'heart': return heartIcon;
case 'fire': return fireIcon;
default: return heartIcon;
default: {
const builtin = AllEmojis.find(e => e.name === name);
return builtin ? builtin.src : heartIcon;
}
}
};
@@ -135,7 +142,7 @@ const createMarkdownComponents = (openExternal) => ({
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
img: ({ node, alt, src, ...props }) => {
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
return <img src={src} alt={alt} style={{ width: '48px', height: '48px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
}
return <img alt={alt} src={src} {...props} />;
},
@@ -189,6 +196,7 @@ const MessageItem = React.memo(({
editInput,
username,
roles,
customEmojis,
onHover,
onLeave,
onContextMenu,
@@ -233,7 +241,7 @@ const MessageItem = React.memo(({
<>
{!isGif && !isDirectVideo && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content, roles))}
{formatEmojis(formatMentions(msg.content, roles), customEmojis)}
</ReactMarkdown>
)}
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
@@ -250,7 +258,7 @@ const MessageItem = React.memo(({
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => (
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsla(240, 4%, 60.784%, 0.078)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
<ColoredIcon src={getReactionIcon(emojiName, customEmojis)} size="16px" color={null} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
</div>
))}
@@ -385,7 +393,8 @@ const MessageItem = React.memo(({
prevProps.showDateDivider === nextProps.showDateDivider &&
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
prevProps.isMentioned === nextProps.isMentioned &&
prevProps.roles === nextProps.roles
prevProps.roles === nextProps.roles &&
prevProps.customEmojis === nextProps.customEmojis
);
});

View File

@@ -1,7 +1,39 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { AllEmojis } from '../assets/emojis';
import AvatarCropModal from './AvatarCropModal';
import Cropper from 'react-easy-crop';
function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.translate(64, 64);
ctx.rotate((rotation * Math.PI) / 180);
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.translate(-64, -64);
ctx.drawImage(
image,
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
0, 0, 128, 128
);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
}, 'image/png');
};
image.onerror = reject;
image.src = imageSrc;
});
}
const TIMEOUT_OPTIONS = [
{ value: 60, label: '1 min' },
@@ -26,6 +58,26 @@ const ServerSettingsModal = ({ onClose }) => {
userId ? { userId } : "skip"
) || {};
// Custom emojis
const customEmojis = useQuery(api.customEmojis.list) || [];
const [showEmojiModal, setShowEmojiModal] = useState(false);
const [emojiPreviewUrl, setEmojiPreviewUrl] = useState(null);
const [emojiName, setEmojiName] = useState('');
const [emojiFile, setEmojiFile] = useState(null);
const [emojiUploading, setEmojiUploading] = useState(false);
const [emojiError, setEmojiError] = useState('');
const emojiFileInputRef = useRef(null);
const [emojiCrop, setEmojiCrop] = useState({ x: 0, y: 0 });
const [emojiZoom, setEmojiZoom] = useState(1);
const [emojiRotation, setEmojiRotation] = useState(0);
const [emojiFlipH, setEmojiFlipH] = useState(false);
const [emojiFlipV, setEmojiFlipV] = useState(false);
const [emojiCroppedAreaPixels, setEmojiCroppedAreaPixels] = useState(null);
const onEmojiCropComplete = useCallback((_croppedArea, croppedPixels) => {
setEmojiCroppedAreaPixels(croppedPixels);
}, []);
// Server settings
const serverSettings = useQuery(api.serverSettings.get);
const channels = useQuery(api.channels.list) || [];
@@ -44,10 +96,18 @@ const ServerSettingsModal = ({ onClose }) => {
const iconInputRef = useRef(null);
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
const handleKey = (e) => {
if (e.key === 'Escape') {
if (showEmojiModal) {
handleEmojiModalClose();
} else if (!showIconCropModal) {
onClose();
}
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
}, [onClose, showEmojiModal, showIconCropModal]);
React.useEffect(() => {
if (serverSettings) {
@@ -171,6 +231,88 @@ const ServerSettingsModal = ({ onClose }) => {
const currentIconUrl = iconPreview || serverSettings?.iconUrl;
const handleEmojiFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, '').substring(0, 32);
setEmojiFile(file);
setEmojiName(name || 'emoji');
setEmojiPreviewUrl(URL.createObjectURL(file));
setEmojiError('');
setShowEmojiModal(true);
e.target.value = '';
};
const handleEmojiModalClose = () => {
setShowEmojiModal(false);
if (emojiPreviewUrl) URL.revokeObjectURL(emojiPreviewUrl);
setEmojiPreviewUrl(null);
setEmojiFile(null);
setEmojiName('');
setEmojiError('');
setEmojiCrop({ x: 0, y: 0 });
setEmojiZoom(1);
setEmojiRotation(0);
setEmojiFlipH(false);
setEmojiFlipV(false);
setEmojiCroppedAreaPixels(null);
};
const handleEmojiUpload = async () => {
if (!userId || !emojiFile || !emojiName.trim()) return;
setEmojiError('');
const name = emojiName.trim();
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
setEmojiError('Name can only contain letters, numbers, and underscores');
return;
}
if (name.length < 2 || name.length > 32) {
setEmojiError('Name must be between 2 and 32 characters');
return;
}
if (AllEmojis.find(e => e.name === name)) {
setEmojiError(`"${name}" conflicts with a built-in emoji`);
return;
}
if (customEmojis.find(e => e.name === name)) {
setEmojiError(`"${name}" already exists as a custom emoji`);
return;
}
setEmojiUploading(true);
try {
let fileToUpload = emojiFile;
if (emojiCroppedAreaPixels && emojiPreviewUrl) {
const blob = await getCroppedEmojiImg(emojiPreviewUrl, emojiCroppedAreaPixels, emojiRotation, emojiFlipH, emojiFlipV);
fileToUpload = new File([blob], 'emoji.png', { type: 'image/png' });
}
const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': fileToUpload.type },
body: fileToUpload,
});
const { storageId } = await res.json();
await convex.mutation(api.customEmojis.upload, { userId, name, storageId });
handleEmojiModalClose();
} catch (e) {
console.error('Failed to upload emoji:', e);
setEmojiError(e.message || 'Failed to upload emoji');
} finally {
setEmojiUploading(false);
}
};
const handleEmojiDelete = async (emojiId) => {
if (!userId) return;
try {
await convex.mutation(api.customEmojis.remove, { userId, emojiId });
} catch (e) {
console.error('Failed to delete emoji:', e);
}
};
const handleCreateRole = async () => {
try {
const newRole = await convex.mutation(api.roles.create, {
@@ -224,7 +366,7 @@ const ServerSettingsModal = ({ onClose }) => {
<div style={{ fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase' }}>
Server Settings
</div>
{['Overview', 'Roles', 'Members'].map(tab => (
{['Overview', 'Emoji', 'Roles', 'Members'].map(tab => (
<div
key={tab}
onClick={() => setActiveTab(tab)}
@@ -367,8 +509,94 @@ const ServerSettingsModal = ({ onClose }) => {
</div>
);
const renderEmojiTab = () => (
<div>
<div style={{ marginBottom: 20 }}>
<p style={{ color: 'var(--header-secondary)', fontSize: 14, margin: '0 0 16px' }}>
Add custom emoji that anyone can use in this server.
</p>
{myPermissions.manage_channels && (
<>
<button
onClick={() => emojiFileInputRef.current?.click()}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 3, padding: '8px 16px', cursor: 'pointer',
fontWeight: 600, fontSize: 14,
}}
>
Upload Emoji
</button>
<input
ref={emojiFileInputRef}
type="file"
accept="image/*,.gif"
onChange={handleEmojiFileSelect}
style={{ display: 'none' }}
/>
</>
)}
</div>
{/* Emoji table */}
<div style={{ borderTop: '1px solid var(--border-subtle)' }}>
{/* Table header */}
<div style={{
display: 'grid', gridTemplateColumns: '48px 1fr 1fr 40px',
padding: '8px 12px', alignItems: 'center',
borderBottom: '1px solid var(--border-subtle)',
}}>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Image</span>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Name</span>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Uploaded By</span>
<span />
</div>
{customEmojis.length === 0 ? (
<div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '40px 0' }}>
No custom emojis yet
</div>
) : (
customEmojis.map(emoji => (
<div
key={emoji._id}
className="emoji-table-row"
style={{
display: 'grid', gridTemplateColumns: '48px 1fr 1fr 40px',
padding: '8px 12px', alignItems: 'center',
borderBottom: '1px solid var(--border-subtle)',
}}
>
<img src={emoji.src} alt={emoji.name} style={{ width: 32, height: 32, objectFit: 'contain' }} />
<span style={{ color: 'var(--header-primary)', fontSize: 15 }}>:{emoji.name}:</span>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{emoji.uploadedByUsername}</span>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{myPermissions.manage_channels && (
<button
onClick={() => handleEmojiDelete(emoji._id)}
style={{
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 16, padding: '4px 8px',
borderRadius: 4, opacity: 0.5, transition: 'opacity 0.15s, color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ed4245'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = 'var(--header-secondary)'; }}
title="Delete emoji"
>
</button>
)}
</div>
</div>
))
)}
</div>
</div>
);
const renderTabContent = () => {
switch (activeTab) {
case 'Emoji': return renderEmojiTab();
case 'Roles': return renderRolesTab();
case 'Members': return renderMembersTab();
default: return (
@@ -552,6 +780,223 @@ const ServerSettingsModal = ({ onClose }) => {
cropShape="rect"
/>
)}
{showEmojiModal && emojiPreviewUrl && (
<div
onClick={handleEmojiModalClose}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
width: 580, maxWidth: '90vw', overflow: 'hidden',
}}
>
{/* Modal header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 16px 0',
}}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
<button
onClick={handleEmojiModalClose}
style={{
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
}}
>
</button>
</div>
{/* Modal body */}
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
{/* Left: Cropper + toolbar + zoom */}
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
width: 240, height: 240, position: 'relative',
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
overflow: 'hidden',
}}>
<Cropper
image={emojiPreviewUrl}
crop={emojiCrop}
zoom={emojiZoom}
rotation={emojiRotation}
aspect={1}
cropShape="rect"
showGrid={false}
onCropChange={setEmojiCrop}
onZoomChange={setEmojiZoom}
onCropComplete={onEmojiCropComplete}
style={{
containerStyle: { width: 240, height: 240 },
mediaStyle: {
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
},
}}
/>
</div>
{/* Toolbar: rotate + flip */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
<button
onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)}
title="Rotate left"
style={{
width: 36, height: 36, borderRadius: 4,
background: 'var(--bg-tertiary)', border: 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button
onClick={() => setEmojiRotation((r) => (r + 90) % 360)}
title="Rotate right"
style={{
width: 36, height: 36, borderRadius: 4,
background: 'var(--bg-tertiary)', border: 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button
onClick={() => setEmojiFlipH((f) => !f)}
title="Flip horizontal"
style={{
width: 36, height: 36, borderRadius: 4,
background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
border: emojiFlipH ? '1px solid #5865F2' : 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
<button
onClick={() => setEmojiFlipV((f) => !f)}
title="Flip vertical"
style={{
width: 36, height: 36, borderRadius: 4,
background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
border: emojiFlipV ? '1px solid #5865F2' : 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
</div>
{/* Zoom slider */}
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
<input
type="range"
min={1}
max={3}
step={0.01}
value={emojiZoom}
onChange={(e) => setEmojiZoom(Number(e.target.value))}
className="avatar-crop-slider"
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
</div>
</div>
{/* Right: Reaction preview + Name + Finish */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Reaction pill preview */}
<div style={{ marginBottom: 16 }}>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
Preview
</span>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
backgroundColor: 'rgba(88, 101, 242, 0.15)',
border: '1px solid var(--brand-experiment, #5865F2)',
borderRadius: 8, padding: '2px 6px', cursor: 'default',
}}>
<img
src={emojiPreviewUrl}
alt=""
style={{
width: 16, height: 16, objectFit: 'contain',
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
}}
/>
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
</div>
</div>
{/* Emoji name input */}
<div style={{ marginBottom: 16 }}>
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
Emoji name <span style={{ color: '#ed4245' }}>*</span>
</label>
<div style={{ position: 'relative' }}>
<input
type="text"
value={emojiName}
onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }}
maxLength={32}
style={{
width: '100%', padding: '10px 32px 10px 10px',
background: 'var(--bg-tertiary)', border: 'none',
borderRadius: 4, color: 'var(--header-primary)', fontSize: 14,
boxSizing: 'border-box',
}}
/>
{emojiName && (
<button
onClick={() => setEmojiName('')}
style={{
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 14, padding: '2px 4px',
}}
>
</button>
)}
</div>
{emojiError && (
<div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>
)}
</div>
<div style={{ flex: 1 }} />
{/* Finish button */}
<button
onClick={handleEmojiUpload}
disabled={emojiUploading || !emojiName.trim()}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer',
fontWeight: 600, fontSize: 14, width: '100%',
opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1,
}}
>
{emojiUploading ? 'Uploading...' : 'Finish'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};