@hamzasaleemorg/convex-comments 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +201 -0
  3. package/README.md +581 -0
  4. package/dist/client/_generated/_ignore.d.ts +1 -0
  5. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  6. package/dist/client/_generated/_ignore.js +3 -0
  7. package/dist/client/_generated/_ignore.js.map +1 -0
  8. package/dist/client/index.d.ts +745 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +579 -0
  11. package/dist/client/index.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +44 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +31 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +673 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +11 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +46 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +11 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +121 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +78 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/convex.config.d.ts +3 -0
  29. package/dist/component/convex.config.d.ts.map +1 -0
  30. package/dist/component/convex.config.js +3 -0
  31. package/dist/component/convex.config.js.map +1 -0
  32. package/dist/component/lib.d.ts +17 -0
  33. package/dist/component/lib.d.ts.map +1 -0
  34. package/dist/component/lib.js +18 -0
  35. package/dist/component/lib.js.map +1 -0
  36. package/dist/component/messages.d.ts +173 -0
  37. package/dist/component/messages.d.ts.map +1 -0
  38. package/dist/component/messages.js +410 -0
  39. package/dist/component/messages.js.map +1 -0
  40. package/dist/component/reactions.d.ts +51 -0
  41. package/dist/component/reactions.d.ts.map +1 -0
  42. package/dist/component/reactions.js +191 -0
  43. package/dist/component/reactions.js.map +1 -0
  44. package/dist/component/schema.d.ts +274 -0
  45. package/dist/component/schema.d.ts.map +1 -0
  46. package/dist/component/schema.js +159 -0
  47. package/dist/component/schema.js.map +1 -0
  48. package/dist/component/threads.d.ts +110 -0
  49. package/dist/component/threads.d.ts.map +1 -0
  50. package/dist/component/threads.js +276 -0
  51. package/dist/component/threads.js.map +1 -0
  52. package/dist/component/typing.d.ts +31 -0
  53. package/dist/component/typing.d.ts.map +1 -0
  54. package/dist/component/typing.js +147 -0
  55. package/dist/component/typing.js.map +1 -0
  56. package/dist/component/zones.d.ts +63 -0
  57. package/dist/component/zones.d.ts.map +1 -0
  58. package/dist/component/zones.js +159 -0
  59. package/dist/component/zones.js.map +1 -0
  60. package/dist/react/AddComment.d.ts +57 -0
  61. package/dist/react/AddComment.d.ts.map +1 -0
  62. package/dist/react/AddComment.js +285 -0
  63. package/dist/react/AddComment.js.map +1 -0
  64. package/dist/react/Comment.d.ts +70 -0
  65. package/dist/react/Comment.d.ts.map +1 -0
  66. package/dist/react/Comment.js +259 -0
  67. package/dist/react/Comment.js.map +1 -0
  68. package/dist/react/Comments.d.ts +74 -0
  69. package/dist/react/Comments.d.ts.map +1 -0
  70. package/dist/react/Comments.js +108 -0
  71. package/dist/react/Comments.js.map +1 -0
  72. package/dist/react/CommentsProvider.d.ts +104 -0
  73. package/dist/react/CommentsProvider.d.ts.map +1 -0
  74. package/dist/react/CommentsProvider.js +98 -0
  75. package/dist/react/CommentsProvider.js.map +1 -0
  76. package/dist/react/ReactionPicker.d.ts +28 -0
  77. package/dist/react/ReactionPicker.d.ts.map +1 -0
  78. package/dist/react/ReactionPicker.js +56 -0
  79. package/dist/react/ReactionPicker.js.map +1 -0
  80. package/dist/react/Thread.d.ts +84 -0
  81. package/dist/react/Thread.d.ts.map +1 -0
  82. package/dist/react/Thread.js +124 -0
  83. package/dist/react/Thread.js.map +1 -0
  84. package/dist/react/TypingIndicator.d.ts +25 -0
  85. package/dist/react/TypingIndicator.d.ts.map +1 -0
  86. package/dist/react/TypingIndicator.js +99 -0
  87. package/dist/react/TypingIndicator.js.map +1 -0
  88. package/dist/react/index.d.ts +15 -0
  89. package/dist/react/index.d.ts.map +1 -0
  90. package/dist/react/index.js +15 -0
  91. package/dist/react/index.js.map +1 -0
  92. package/package.json +106 -0
  93. package/src/client/_generated/_ignore.ts +1 -0
  94. package/src/client/index.ts +813 -0
  95. package/src/component/_generated/api.ts +60 -0
  96. package/src/component/_generated/component.ts +784 -0
  97. package/src/component/_generated/dataModel.ts +60 -0
  98. package/src/component/_generated/server.ts +156 -0
  99. package/src/component/convex.config.ts +3 -0
  100. package/src/component/lib.ts +57 -0
  101. package/src/component/messages.ts +476 -0
  102. package/src/component/reactions.ts +222 -0
  103. package/src/component/schema.ts +169 -0
  104. package/src/component/threads.ts +319 -0
  105. package/src/component/typing.ts +168 -0
  106. package/src/component/zones.ts +180 -0
  107. package/src/react/AddComment.tsx +463 -0
  108. package/src/react/Comment.tsx +519 -0
  109. package/src/react/Comments.tsx +276 -0
  110. package/src/react/CommentsProvider.tsx +197 -0
  111. package/src/react/ReactionPicker.tsx +95 -0
  112. package/src/react/Thread.tsx +336 -0
  113. package/src/react/TypingIndicator.tsx +144 -0
  114. package/src/react/index.ts +45 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Comments Component
3
+ *
4
+ * Top-level component that displays all threads in a zone.
5
+ */
6
+
7
+ import { type ReactNode } from "react";
8
+ import { useComments } from "./CommentsProvider";
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ export interface ThreadData {
15
+ thread: {
16
+ _id: string;
17
+ zoneId: string;
18
+ resolved: boolean;
19
+ resolvedBy?: string;
20
+ resolvedAt?: number;
21
+ createdAt: number;
22
+ lastActivityAt: number;
23
+ position?: { x: number; y: number; anchor?: string };
24
+ metadata?: unknown;
25
+ };
26
+ firstMessage: {
27
+ _id: string;
28
+ body: string;
29
+ authorId: string;
30
+ createdAt: number;
31
+ } | null;
32
+ messageCount: number;
33
+ }
34
+
35
+ export interface CommentsProps {
36
+ /** Threads data from getThreads query */
37
+ threads: ThreadData[];
38
+ /** Whether there are more threads to load */
39
+ hasMore?: boolean;
40
+ /** Loading state */
41
+ isLoading?: boolean;
42
+ /** Callback when load more is clicked */
43
+ onLoadMore?: () => void;
44
+ /** Callback when a thread is clicked */
45
+ onThreadClick?: (threadId: string) => void;
46
+ /** Callback when new thread button is clicked */
47
+ onNewThread?: () => void;
48
+ /** Callback when thread is resolved */
49
+ onResolveThread?: (threadId: string) => void;
50
+ /** Callback when thread is unresolve */
51
+ onUnresolveThread?: (threadId: string) => void;
52
+ /** Whether to show resolved threads */
53
+ showResolved?: boolean;
54
+ /** Custom thread renderer */
55
+ renderThread?: (thread: ThreadData) => ReactNode;
56
+ /** Empty state message */
57
+ emptyMessage?: string;
58
+ /** CSS class name */
59
+ className?: string;
60
+ }
61
+
62
+ // ============================================================================
63
+ // Component
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Main comments component that displays all threads in a zone.
68
+ *
69
+ * Usage:
70
+ * ```tsx
71
+ * const { threads, hasMore } = useQuery(api.comments.getThreads, { zoneId });
72
+ *
73
+ * <Comments
74
+ * threads={threads}
75
+ * hasMore={hasMore}
76
+ * onLoadMore={loadMore}
77
+ * onThreadClick={(id) => setSelectedThread(id)}
78
+ * onNewThread={() => createThread()}
79
+ * />
80
+ * ```
81
+ */
82
+ export function Comments({
83
+ threads,
84
+ hasMore = false,
85
+ isLoading = false,
86
+ onLoadMore,
87
+ onThreadClick,
88
+ onNewThread,
89
+ onResolveThread,
90
+ onUnresolveThread,
91
+ showResolved = true,
92
+ renderThread,
93
+ emptyMessage = "No comments yet. Start a conversation!",
94
+ className = "",
95
+ }: CommentsProps) {
96
+ const { userId, styles } = useComments();
97
+
98
+
99
+ // Filter threads based on showResolved
100
+ const visibleThreads = showResolved
101
+ ? threads
102
+ : threads.filter((t) => !t.thread.resolved);
103
+
104
+
105
+
106
+ // Default thread renderer
107
+ const defaultRenderThread = (data: ThreadData) => {
108
+ const { thread, firstMessage, messageCount } = data;
109
+ const isResolved = thread.resolved;
110
+
111
+ return (
112
+ <div
113
+ key={thread._id}
114
+ className={`comments-thread-preview ${isResolved ? "resolved" : ""}`}
115
+ onClick={() => onThreadClick?.(thread._id)}
116
+ style={{
117
+ padding: "12px 16px",
118
+ borderBottom: "1px solid #e5e7eb",
119
+ cursor: "pointer",
120
+ opacity: isResolved ? 0.6 : 1,
121
+ backgroundColor: isResolved ? "#f9fafb" : "white",
122
+ }}
123
+ >
124
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
125
+ <div style={{ flex: 1, minWidth: 0 }}>
126
+ {firstMessage ? (
127
+ <>
128
+ <div
129
+ style={{
130
+ fontSize: "14px",
131
+ color: "#111827",
132
+ whiteSpace: "nowrap",
133
+ overflow: "hidden",
134
+ textOverflow: "ellipsis",
135
+ }}
136
+ >
137
+ {firstMessage.body}
138
+ </div>
139
+ <div style={{ fontSize: "12px", color: "#6b7280", marginTop: "4px" }}>
140
+ {messageCount} {messageCount === 1 ? "message" : "messages"}
141
+ {isResolved && " • Resolved"}
142
+ </div>
143
+ </>
144
+ ) : (
145
+ <div style={{ fontSize: "14px", color: "#6b7280", fontStyle: "italic" }}>
146
+ Empty thread
147
+ </div>
148
+ )}
149
+ </div>
150
+ {isResolved && onUnresolveThread && (
151
+ <button
152
+ onClick={(e) => {
153
+ e.stopPropagation();
154
+ onUnresolveThread(thread._id);
155
+ }}
156
+ style={{
157
+ padding: "4px 8px",
158
+ fontSize: "12px",
159
+ border: "1px solid #d1d5db",
160
+ borderRadius: "4px",
161
+ background: "white",
162
+ cursor: "pointer",
163
+ }}
164
+ >
165
+ Reopen
166
+ </button>
167
+ )}
168
+ {!isResolved && onResolveThread && (
169
+ <button
170
+ onClick={(e) => {
171
+ e.stopPropagation();
172
+ onResolveThread(thread._id);
173
+ }}
174
+ style={{
175
+ padding: "4px 8px",
176
+ fontSize: "12px",
177
+ border: "1px solid #d1d5db",
178
+ borderRadius: "4px",
179
+ background: "white",
180
+ cursor: "pointer",
181
+ }}
182
+ >
183
+ Resolve
184
+ </button>
185
+ )}
186
+ </div>
187
+ </div>
188
+ );
189
+ };
190
+
191
+ return (
192
+ <div
193
+ className={`comments-container ${className}`}
194
+ style={{
195
+ fontFamily: styles?.fontFamily ?? "system-ui, -apple-system, sans-serif",
196
+ border: "1px solid #e5e7eb",
197
+ borderRadius: styles?.borderRadius ?? "8px",
198
+ overflow: "hidden",
199
+ }}
200
+ >
201
+ {/* Header */}
202
+ <div
203
+ style={{
204
+ padding: "12px 16px",
205
+ borderBottom: "1px solid #e5e7eb",
206
+ display: "flex",
207
+ justifyContent: "space-between",
208
+ alignItems: "center",
209
+ backgroundColor: "#f9fafb",
210
+ }}
211
+ >
212
+ <span style={{ fontWeight: 600, fontSize: "14px" }}>
213
+ Comments ({threads.length})
214
+ </span>
215
+ {onNewThread && userId && (
216
+ <button
217
+ onClick={onNewThread}
218
+ style={{
219
+ padding: "6px 12px",
220
+ fontSize: "13px",
221
+ fontWeight: 500,
222
+ border: "none",
223
+ borderRadius: "6px",
224
+ backgroundColor: styles?.accentColor ?? "#3b82f6",
225
+ color: "white",
226
+ cursor: "pointer",
227
+ }}
228
+ >
229
+ + New Thread
230
+ </button>
231
+ )}
232
+ </div>
233
+
234
+ {/* Thread list */}
235
+ <div style={{ maxHeight: "400px", overflowY: "auto" }}>
236
+ {visibleThreads.length === 0 ? (
237
+ <div
238
+ style={{
239
+ padding: "40px 16px",
240
+ textAlign: "center",
241
+ color: "#6b7280",
242
+ fontSize: "14px",
243
+ }}
244
+ >
245
+ {emptyMessage}
246
+ </div>
247
+ ) : (
248
+ visibleThreads.map((data) =>
249
+ renderThread ? renderThread(data) : defaultRenderThread(data)
250
+ )
251
+ )}
252
+
253
+ {/* Load more */}
254
+ {hasMore && (
255
+ <div style={{ padding: "12px 16px", textAlign: "center" }}>
256
+ <button
257
+ onClick={onLoadMore}
258
+ disabled={isLoading}
259
+ style={{
260
+ padding: "8px 16px",
261
+ fontSize: "13px",
262
+ border: "1px solid #d1d5db",
263
+ borderRadius: "6px",
264
+ background: "white",
265
+ cursor: isLoading ? "not-allowed" : "pointer",
266
+ opacity: isLoading ? 0.6 : 1,
267
+ }}
268
+ >
269
+ {isLoading ? "Loading..." : "Load More"}
270
+ </button>
271
+ </div>
272
+ )}
273
+ </div>
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Comments Context Provider
3
+ *
4
+ * Provides the Comments API instance to all child components.
5
+ */
6
+
7
+ import { createContext, useContext, useMemo, type ReactNode, type CSSProperties } from "react";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface CommentsStyles {
14
+ /** Primary accent color (buttons, links, highlights) */
15
+ accentColor?: string;
16
+ /** Background color for the comments container */
17
+ backgroundColor?: string;
18
+ /** Text color */
19
+ textColor?: string;
20
+ /** Secondary/muted text color */
21
+ textColorMuted?: string;
22
+ /** Border color */
23
+ borderColor?: string;
24
+ /** Border radius for cards and buttons */
25
+ borderRadius?: string;
26
+ /** Font family */
27
+ fontFamily?: string;
28
+ /** Base font size */
29
+ fontSize?: string;
30
+ }
31
+
32
+ export interface CommentsContextValue {
33
+ /** Current user ID */
34
+ userId: string | null;
35
+ /** Function to resolve user display info */
36
+ resolveUser?: (userId: string) => Promise<{ name: string; avatar?: string }> | { name: string; avatar?: string };
37
+ /** Available reaction emojis */
38
+ reactionChoices: string[];
39
+ /** Whether the current user can edit/delete messages */
40
+ canModerate?: boolean;
41
+ /** Custom styles */
42
+ styles?: CommentsStyles;
43
+ /** CSS variables for theming */
44
+ cssVars: Record<string, string>;
45
+ }
46
+
47
+ // ============================================================================
48
+ // Default Theme
49
+ // ============================================================================
50
+
51
+ const defaultStyles: Required<CommentsStyles> = {
52
+ accentColor: "#3b82f6",
53
+ backgroundColor: "#ffffff",
54
+ textColor: "#1f2937",
55
+ textColorMuted: "#6b7280",
56
+ borderColor: "#e5e7eb",
57
+ borderRadius: "8px",
58
+ fontFamily: "system-ui, -apple-system, sans-serif",
59
+ fontSize: "14px",
60
+ };
61
+
62
+ // ============================================================================
63
+ // Context
64
+ // ============================================================================
65
+
66
+ const CommentsContext = createContext<CommentsContextValue | null>(null);
67
+
68
+ // ============================================================================
69
+ // Provider
70
+ // ============================================================================
71
+
72
+ export interface CommentsProviderProps {
73
+ /** Current user ID (null if not authenticated) */
74
+ userId: string | null;
75
+ /** Function to resolve user info for display */
76
+ resolveUser?: (userId: string) => Promise<{ name: string; avatar?: string }> | { name: string; avatar?: string };
77
+ /** Available reaction emojis (defaults to common emojis) */
78
+ reactionChoices?: string[];
79
+ /** Whether the current user has moderator privileges */
80
+ canModerate?: boolean;
81
+ /**
82
+ * Custom styling options. Can also use CSS variables:
83
+ * --comments-accent-color, --comments-bg-color, etc.
84
+ */
85
+ styles?: CommentsStyles;
86
+ /** Optional class name for the wrapper */
87
+ className?: string;
88
+ /** Child components */
89
+ children: ReactNode;
90
+ }
91
+
92
+ /**
93
+ * Provider component that supplies comments configuration to all child components.
94
+ *
95
+ * Usage:
96
+ * ```tsx
97
+ * <CommentsProvider
98
+ * userId={currentUserId}
99
+ * resolveUser={async (id) => ({ name: users[id].name, avatar: users[id].avatar })}
100
+ * reactionChoices={["👍", "❤️", "😄", "🎉", "😮", "😢"]}
101
+ * styles={{
102
+ * accentColor: "#8b5cf6",
103
+ * borderRadius: "12px",
104
+ * }}
105
+ * >
106
+ * <Comments threads={threads} />
107
+ * </CommentsProvider>
108
+ * ```
109
+ *
110
+ * CSS Variables (can be set in your CSS):
111
+ * - --comments-accent-color
112
+ * - --comments-bg-color
113
+ * - --comments-text-color
114
+ * - --comments-text-color-muted
115
+ * - --comments-border-color
116
+ * - --comments-border-radius
117
+ * - --comments-font-family
118
+ * - --comments-font-size
119
+ */
120
+ export function CommentsProvider({
121
+ userId,
122
+ resolveUser,
123
+ reactionChoices = ["👍", "❤️", "😄", "🎉", "😮", "😢", "👀", "🚀"],
124
+ canModerate = false,
125
+ styles,
126
+ className,
127
+ children,
128
+ }: CommentsProviderProps) {
129
+ const mergedStyles = useMemo<Required<CommentsStyles>>(
130
+ () => ({
131
+ ...defaultStyles,
132
+ ...styles,
133
+ }),
134
+ [styles]
135
+ );
136
+
137
+ const cssVars = useMemo<Record<string, string>>(
138
+ () => ({
139
+ "--comments-accent-color": mergedStyles.accentColor,
140
+ "--comments-bg-color": mergedStyles.backgroundColor,
141
+ "--comments-text-color": mergedStyles.textColor,
142
+ "--comments-text-color-muted": mergedStyles.textColorMuted,
143
+ "--comments-border-color": mergedStyles.borderColor,
144
+ "--comments-border-radius": mergedStyles.borderRadius,
145
+ "--comments-font-family": mergedStyles.fontFamily,
146
+ "--comments-font-size": mergedStyles.fontSize,
147
+ }),
148
+ [mergedStyles]
149
+ );
150
+
151
+ const value = useMemo<CommentsContextValue>(
152
+ () => ({
153
+ userId,
154
+ resolveUser,
155
+ reactionChoices,
156
+ canModerate,
157
+ styles: mergedStyles,
158
+ cssVars,
159
+ }),
160
+ [userId, resolveUser, reactionChoices, canModerate, mergedStyles, cssVars]
161
+ );
162
+
163
+ const wrapperStyle: CSSProperties = {
164
+ ...cssVars as CSSProperties,
165
+ fontFamily: mergedStyles.fontFamily,
166
+ fontSize: mergedStyles.fontSize,
167
+ color: mergedStyles.textColor,
168
+ };
169
+
170
+ return (
171
+ <CommentsContext.Provider value={value}>
172
+ <div
173
+ className={`comments-provider ${className ?? ""}`}
174
+ style={wrapperStyle}
175
+ data-comments-provider=""
176
+ >
177
+ {children}
178
+ </div>
179
+ </CommentsContext.Provider>
180
+ );
181
+ }
182
+
183
+ // ============================================================================
184
+ // Hook
185
+ // ============================================================================
186
+
187
+ /**
188
+ * Hook to access the comments context.
189
+ * Must be used within a CommentsProvider.
190
+ */
191
+ export function useComments(): CommentsContextValue {
192
+ const context = useContext(CommentsContext);
193
+ if (!context) {
194
+ throw new Error("useComments must be used within a CommentsProvider");
195
+ }
196
+ return context;
197
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ReactionPicker Component
3
+ *
4
+ * A popup picker for selecting emoji reactions.
5
+ */
6
+
7
+ import { useComments } from "./CommentsProvider";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface ReactionPickerProps {
14
+ /** Callback when an emoji is selected */
15
+ onSelect: (emoji: string) => void;
16
+ /** Callback when the picker is closed */
17
+ onClose?: () => void;
18
+ /** Custom reaction choices (overrides provider) */
19
+ reactionChoices?: string[];
20
+ /** CSS class name */
21
+ className?: string;
22
+ }
23
+
24
+ // ============================================================================
25
+ // Component
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Emoji reaction picker popup.
30
+ *
31
+ * Usage:
32
+ * ```tsx
33
+ * <ReactionPicker
34
+ * onSelect={(emoji) => toggleReaction({ messageId, emoji })}
35
+ * onClose={() => setShowPicker(false)}
36
+ * />
37
+ * ```
38
+ */
39
+ export function ReactionPicker({
40
+ onSelect,
41
+ onClose,
42
+ reactionChoices: customChoices,
43
+ className = "",
44
+ }: ReactionPickerProps) {
45
+ const { reactionChoices: providerChoices, styles } = useComments();
46
+ const choices = customChoices ?? providerChoices;
47
+
48
+ return (
49
+ <div
50
+ className={`reaction-picker ${className}`}
51
+ style={{
52
+ display: "flex",
53
+ flexWrap: "wrap",
54
+ gap: "4px",
55
+ padding: "8px",
56
+ backgroundColor: "white",
57
+ borderRadius: styles?.borderRadius ?? "8px",
58
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
59
+ border: "1px solid #e5e7eb",
60
+ maxWidth: "200px",
61
+ }}
62
+ >
63
+ {choices.map((emoji) => (
64
+ <button
65
+ key={emoji}
66
+ onClick={() => {
67
+ onSelect(emoji);
68
+ onClose?.();
69
+ }}
70
+ style={{
71
+ width: "32px",
72
+ height: "32px",
73
+ display: "flex",
74
+ alignItems: "center",
75
+ justifyContent: "center",
76
+ border: "none",
77
+ background: "transparent",
78
+ borderRadius: "4px",
79
+ cursor: "pointer",
80
+ fontSize: "18px",
81
+ transition: "background-color 0.15s",
82
+ }}
83
+ onMouseEnter={(e) => {
84
+ e.currentTarget.style.backgroundColor = "#f3f4f6";
85
+ }}
86
+ onMouseLeave={(e) => {
87
+ e.currentTarget.style.backgroundColor = "transparent";
88
+ }}
89
+ >
90
+ {emoji}
91
+ </button>
92
+ ))}
93
+ </div>
94
+ );
95
+ }