Add custom emojis
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s
This commit is contained in:
11
TODO.md
11
TODO.md
@@ -1,18 +1,9 @@
|
|||||||
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
|
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
|
||||||
|
|
||||||
- Fix green status not updating correctly
|
|
||||||
|
|
||||||
# Future
|
# Future
|
||||||
|
|
||||||
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
|
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
|
||||||
|
|
||||||
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
|
|
||||||
|
|
||||||
- Add photo / video albums like Commit https://commet.chat/
|
- Add photo / video albums like Commit https://commet.chat/
|
||||||
|
|
||||||
|
Lets allow custom emojis in the server. So if you go to the server settings on discord you can upload custom emojies. You put a image, a emoji name, and then you can use it in the chat like :emoji_name:. Discord resizes the image to a max of 32px thats either width or height depending on the image aspect ratio. The allow transparent background images and gif's. We dont allow duplicate names to existing emojis that we already have by default or custom emojies
|
||||||
For the main admin can we keep track of how much space a user is taking in the database.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Is it possible with large files we do a torrent or peer to peer based file sharing. So users that want to share really large files dont store it on our servers but share it using peer to peer.
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.20",
|
"version": "1.0.21",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -12,6 +12,7 @@ import type * as auth from "../auth.js";
|
|||||||
import type * as categories from "../categories.js";
|
import type * as categories from "../categories.js";
|
||||||
import type * as channelKeys from "../channelKeys.js";
|
import type * as channelKeys from "../channelKeys.js";
|
||||||
import type * as channels from "../channels.js";
|
import type * as channels from "../channels.js";
|
||||||
|
import type * as customEmojis from "../customEmojis.js";
|
||||||
import type * as dms from "../dms.js";
|
import type * as dms from "../dms.js";
|
||||||
import type * as files from "../files.js";
|
import type * as files from "../files.js";
|
||||||
import type * as gifs from "../gifs.js";
|
import type * as gifs from "../gifs.js";
|
||||||
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
channelKeys: typeof channelKeys;
|
channelKeys: typeof channelKeys;
|
||||||
channels: typeof channels;
|
channels: typeof channels;
|
||||||
|
customEmojis: typeof customEmojis;
|
||||||
dms: typeof dms;
|
dms: typeof dms;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
gifs: typeof gifs;
|
gifs: typeof gifs;
|
||||||
|
|||||||
98
convex/customEmojis.ts
Normal file
98
convex/customEmojis.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getRolesForUser } from "./roles";
|
||||||
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const emojis = await ctx.db.query("customEmojis").collect();
|
||||||
|
const results = await Promise.all(
|
||||||
|
emojis.map(async (emoji) => {
|
||||||
|
const src = await getPublicStorageUrl(ctx, emoji.storageId);
|
||||||
|
const user = await ctx.db.get(emoji.uploadedBy);
|
||||||
|
return {
|
||||||
|
_id: emoji._id,
|
||||||
|
name: emoji.name,
|
||||||
|
src,
|
||||||
|
createdAt: emoji.createdAt,
|
||||||
|
uploadedByUsername: user?.username || "Unknown",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return results.filter((e) => e.src !== null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upload = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
name: v.string(),
|
||||||
|
storageId: v.id("_storage"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Permission check
|
||||||
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
|
const canManage = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error("You don't have permission to manage emojis");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format
|
||||||
|
const name = args.name.trim();
|
||||||
|
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||||
|
throw new Error("Emoji name can only contain letters, numbers, and underscores");
|
||||||
|
}
|
||||||
|
if (name.length < 2 || name.length > 32) {
|
||||||
|
throw new Error("Emoji name must be between 2 and 32 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name among custom emojis
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("customEmojis")
|
||||||
|
.withIndex("by_name", (q) => q.eq("name", name))
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`A custom emoji named "${name}" already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert("customEmojis", {
|
||||||
|
name,
|
||||||
|
storageId: args.storageId,
|
||||||
|
uploadedBy: args.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
emojiId: v.id("customEmojis"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Permission check
|
||||||
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
|
const canManage = roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
|
||||||
|
);
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error("You don't have permission to manage emojis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = await ctx.db.get(args.emojiId);
|
||||||
|
if (!emoji) throw new Error("Emoji not found");
|
||||||
|
|
||||||
|
await ctx.storage.delete(emoji.storageId);
|
||||||
|
await ctx.db.delete(args.emojiId);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -132,4 +132,11 @@ export default defineSchema({
|
|||||||
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
||||||
iconStorageId: v.optional(v.id("_storage")),
|
iconStorageId: v.optional(v.id("_storage")),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
customEmojis: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
storageId: v.id("_storage"),
|
||||||
|
uploadedBy: v.id("userProfiles"),
|
||||||
|
createdAt: v.number(),
|
||||||
|
}).index("by_name", ["name"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.20",
|
"version": "1.0.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const [mentionQuery, setMentionQuery] = useState(null);
|
const [mentionQuery, setMentionQuery] = useState(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
||||||
|
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
||||||
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesContainerRef = 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 members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
||||||
const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || [];
|
const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || [];
|
||||||
const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip");
|
const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip");
|
||||||
|
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||||
|
|
||||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||||
api.messages.list,
|
api.messages.list,
|
||||||
@@ -765,6 +767,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setEditingMessage(null);
|
setEditingMessage(null);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
setUnreadDividerTimestamp(null);
|
setUnreadDividerTimestamp(null);
|
||||||
|
setReactionPickerMsgId(null);
|
||||||
onTogglePinned();
|
onTogglePinned();
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
@@ -1013,7 +1016,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const name = match[1];
|
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;
|
if (!emoji) return;
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
@@ -1278,7 +1281,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
deleteMessageMutation({ id: msg.id, userId: currentUserId });
|
deleteMessageMutation({ id: msg.id, userId: currentUserId });
|
||||||
break;
|
break;
|
||||||
case 'reaction':
|
case 'reaction':
|
||||||
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
|
setReactionPickerMsgId(msg.id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -1382,6 +1385,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
isMentioned={isMentioned}
|
isMentioned={isMentioned}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
customEmojis={customEmojis}
|
||||||
isEditing={editingMessage?.id === msg.id}
|
isEditing={editingMessage?.id === msg.id}
|
||||||
isHovered={hoveredMessageId === msg.id}
|
isHovered={hoveredMessageId === msg.id}
|
||||||
editInput={editInput}
|
editInput={editInput}
|
||||||
@@ -1389,7 +1393,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
onHover={() => setHoveredMessageId(msg.id)}
|
onHover={() => setHoveredMessageId(msg.id)}
|
||||||
onLeave={() => setHoveredMessageId(null)}
|
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 }); }}
|
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); }}
|
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) })}
|
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 }); }}
|
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>
|
||||||
</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)} />}
|
{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 () => {
|
{inputContextMenu && <InputContextMenu x={inputContextMenu.x} y={inputContextMenu.y} onClose={() => setInputContextMenu(null)} onPaste={async () => {
|
||||||
try {
|
try {
|
||||||
if (inputDivRef.current) inputDivRef.current.focus();
|
if (inputDivRef.current) inputDivRef.current.focus();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
|
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useConvex } from 'convex/react';
|
import { useConvex, useQuery } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
const EmojiItem = ({ emoji, onSelect }) => (
|
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) {
|
if (search) {
|
||||||
const filtered = AllEmojis
|
const q = search.toLowerCase().replace(/:/g, '');
|
||||||
.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
|
const customFiltered = customEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||||
.slice(0, 100);
|
const builtinFiltered = AllEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||||
|
const filtered = [...customFiltered, ...builtinFiltered].slice(0, 100);
|
||||||
return (
|
return (
|
||||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||||
<div style={emojiGridStyle}>
|
<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 (
|
return (
|
||||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
<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]) => (
|
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
|
||||||
<div key={category} style={{ marginBottom: '8px' }}>
|
<div key={category} style={{ marginBottom: '8px' }}>
|
||||||
<div
|
<CategoryHeader name={category} collapsed={collapsedCategories[category]} />
|
||||||
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>
|
|
||||||
{!collapsedCategories[category] && (
|
{!collapsedCategories[category] && (
|
||||||
<div style={emojiGridStyle}>
|
<div style={emojiGridStyle}>
|
||||||
{emojis.map((emoji, idx) => (
|
{emojis.map((emoji, idx) => (
|
||||||
@@ -181,6 +198,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||||
|
|
||||||
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
||||||
const setActiveTab = (tab) => {
|
const setActiveTab = (tab) => {
|
||||||
@@ -314,7 +332,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
) : activeTab === 'GIFs' ? (
|
) : activeTab === 'GIFs' ? (
|
||||||
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ export const formatMentions = (text, roles) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatEmojis = (text) => {
|
export const formatEmojis = (text, customEmojis = []) => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||||
|
const custom = customEmojis.find(e => e.name === name);
|
||||||
|
if (custom) return ``;
|
||||||
const emoji = AllEmojis.find(e => e.name === name);
|
const emoji = AllEmojis.find(e => e.name === name);
|
||||||
return emoji ? `` : match;
|
return emoji ? `` : match;
|
||||||
});
|
});
|
||||||
@@ -91,12 +93,17 @@ export const parseSystemMessage = (content) => {
|
|||||||
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
||||||
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
|
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) {
|
switch (name) {
|
||||||
case 'thumbsup': return thumbsupIcon;
|
case 'thumbsup': return thumbsupIcon;
|
||||||
case 'heart': return heartIcon;
|
case 'heart': return heartIcon;
|
||||||
case 'fire': return fireIcon;
|
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} />,
|
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
||||||
img: ({ node, alt, src, ...props }) => {
|
img: ({ node, alt, src, ...props }) => {
|
||||||
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
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} />;
|
return <img alt={alt} src={src} {...props} />;
|
||||||
},
|
},
|
||||||
@@ -189,6 +196,7 @@ const MessageItem = React.memo(({
|
|||||||
editInput,
|
editInput,
|
||||||
username,
|
username,
|
||||||
roles,
|
roles,
|
||||||
|
customEmojis,
|
||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
@@ -233,7 +241,7 @@ const MessageItem = React.memo(({
|
|||||||
<>
|
<>
|
||||||
{!isGif && !isDirectVideo && (
|
{!isGif && !isDirectVideo && (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||||
{formatEmojis(formatMentions(msg.content, roles))}
|
{formatEmojis(formatMentions(msg.content, roles), customEmojis)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
)}
|
)}
|
||||||
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
{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' }}>
|
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
{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' }}>
|
<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>
|
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -385,7 +393,8 @@ const MessageItem = React.memo(({
|
|||||||
prevProps.showDateDivider === nextProps.showDateDivider &&
|
prevProps.showDateDivider === nextProps.showDateDivider &&
|
||||||
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
||||||
prevProps.isMentioned === nextProps.isMentioned &&
|
prevProps.isMentioned === nextProps.isMentioned &&
|
||||||
prevProps.roles === nextProps.roles
|
prevProps.roles === nextProps.roles &&
|
||||||
|
prevProps.customEmojis === nextProps.customEmojis
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useQuery, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import { AllEmojis } from '../assets/emojis';
|
||||||
import AvatarCropModal from './AvatarCropModal';
|
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 = [
|
const TIMEOUT_OPTIONS = [
|
||||||
{ value: 60, label: '1 min' },
|
{ value: 60, label: '1 min' },
|
||||||
@@ -26,6 +58,26 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
userId ? { userId } : "skip"
|
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
|
// Server settings
|
||||||
const serverSettings = useQuery(api.serverSettings.get);
|
const serverSettings = useQuery(api.serverSettings.get);
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
@@ -44,10 +96,18 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
const iconInputRef = useRef(null);
|
const iconInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
window.addEventListener('keydown', handleKey);
|
||||||
return () => window.removeEventListener('keydown', handleKey);
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
}, [onClose]);
|
}, [onClose, showEmojiModal, showIconCropModal]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (serverSettings) {
|
if (serverSettings) {
|
||||||
@@ -171,6 +231,88 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
|
|
||||||
const currentIconUrl = iconPreview || serverSettings?.iconUrl;
|
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 () => {
|
const handleCreateRole = async () => {
|
||||||
try {
|
try {
|
||||||
const newRole = await convex.mutation(api.roles.create, {
|
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' }}>
|
<div style={{ fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase' }}>
|
||||||
Server Settings
|
Server Settings
|
||||||
</div>
|
</div>
|
||||||
{['Overview', 'Roles', 'Members'].map(tab => (
|
{['Overview', 'Emoji', 'Roles', 'Members'].map(tab => (
|
||||||
<div
|
<div
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -367,8 +509,94 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
</div>
|
</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 = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
case 'Emoji': return renderEmojiTab();
|
||||||
case 'Roles': return renderRolesTab();
|
case 'Roles': return renderRolesTab();
|
||||||
case 'Members': return renderMembersTab();
|
case 'Members': return renderMembersTab();
|
||||||
default: return (
|
default: return (
|
||||||
@@ -552,6 +780,223 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
cropShape="rect"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user