@ermis-network/ermis-chat-react 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.
- package/dist/index.cjs +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- package/src/utils.ts +242 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Panel } from '../Panel';
|
|
3
|
+
import type { ChannelSettingsPanelProps } from '../../types';
|
|
4
|
+
|
|
5
|
+
export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.memo(({
|
|
6
|
+
isOpen,
|
|
7
|
+
onClose,
|
|
8
|
+
channel,
|
|
9
|
+
title = 'Channel Settings',
|
|
10
|
+
slowModeOptions = [
|
|
11
|
+
{ label: 'Off', value: 0 },
|
|
12
|
+
{ label: '10s', value: 10000 },
|
|
13
|
+
{ label: '30s', value: 30000 },
|
|
14
|
+
{ label: '1m', value: 60000 },
|
|
15
|
+
{ label: '5m', value: 300000 },
|
|
16
|
+
{ label: '15m', value: 900000 },
|
|
17
|
+
{ label: '1h', value: 3600000 },
|
|
18
|
+
],
|
|
19
|
+
}) => {
|
|
20
|
+
// Config state
|
|
21
|
+
const [slowMode, setSlowMode] = useState<number>(0);
|
|
22
|
+
const [capabilities, setCapabilities] = useState<Record<string, boolean>>({
|
|
23
|
+
'send-message': true,
|
|
24
|
+
'send-links': true,
|
|
25
|
+
'update-own-message': true,
|
|
26
|
+
'delete-own-message': true,
|
|
27
|
+
'send-reaction': true,
|
|
28
|
+
'pin-message': true,
|
|
29
|
+
'create-poll': true,
|
|
30
|
+
'vote-poll': true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const [keywords, setKeywords] = useState<string[]>([]);
|
|
34
|
+
const [newKeyword, setNewKeyword] = useState('');
|
|
35
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
36
|
+
const [error, setError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
// Sync state when panel opens or channel updates
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!channel) return;
|
|
41
|
+
|
|
42
|
+
const syncData = (dataToSync = channel.data) => {
|
|
43
|
+
console.log('---syncData---', dataToSync);
|
|
44
|
+
setSlowMode((dataToSync?.member_message_cooldown as number) || 0);
|
|
45
|
+
setKeywords((dataToSync?.filter_words as string[]) || []);
|
|
46
|
+
|
|
47
|
+
const caps = dataToSync?.member_capabilities as string[] || [];
|
|
48
|
+
setCapabilities({
|
|
49
|
+
'send-message': caps.includes('send-message'),
|
|
50
|
+
'send-links': caps.includes('send-links'),
|
|
51
|
+
'update-own-message': caps.includes('update-own-message'),
|
|
52
|
+
'delete-own-message': caps.includes('delete-own-message'),
|
|
53
|
+
'send-reaction': caps.includes('send-reaction'),
|
|
54
|
+
'pin-message': caps.includes('pin-message'),
|
|
55
|
+
'create-poll': caps.includes('create-poll'),
|
|
56
|
+
'vote-poll': caps.includes('vote-poll'),
|
|
57
|
+
});
|
|
58
|
+
setError(null);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (isOpen) {
|
|
62
|
+
syncData();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Listen to real-time changes
|
|
66
|
+
const subscription = channel.on('channel.updated', (event: any) => {
|
|
67
|
+
const latestData = event?.channel || channel.data;
|
|
68
|
+
// Force mutating local channel.data to ensure future syncData hits cache
|
|
69
|
+
if (event?.channel && channel.data) {
|
|
70
|
+
Object.assign(channel.data, event.channel);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isOpen) {
|
|
74
|
+
syncData(latestData);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
subscription?.unsubscribe();
|
|
80
|
+
};
|
|
81
|
+
}, [isOpen, channel]);
|
|
82
|
+
|
|
83
|
+
const toggleCapability = (key: string) => {
|
|
84
|
+
setCapabilities(prev => ({ ...prev, [key]: !prev[key] }));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Compute dirty state
|
|
88
|
+
const isSlowModeChanged = slowMode !== ((channel?.data?.member_message_cooldown as number) || 0);
|
|
89
|
+
|
|
90
|
+
const currentKeywordsSorted = [...keywords].sort().join(',');
|
|
91
|
+
const originalKeywordsSorted = [...((channel?.data?.filter_words as string[]) || [])].sort().join(',');
|
|
92
|
+
const isKeywordsChanged = currentKeywordsSorted !== originalKeywordsSorted;
|
|
93
|
+
|
|
94
|
+
const originalCapabilities = channel?.data?.member_capabilities as string[] || [];
|
|
95
|
+
const initialCapabilities: Record<string, boolean> = {
|
|
96
|
+
'send-message': originalCapabilities.includes('send-message'),
|
|
97
|
+
'send-links': originalCapabilities.includes('send-links'),
|
|
98
|
+
'update-own-message': originalCapabilities.includes('update-own-message'),
|
|
99
|
+
'delete-own-message': originalCapabilities.includes('delete-own-message'),
|
|
100
|
+
'send-reaction': originalCapabilities.includes('send-reaction'),
|
|
101
|
+
'pin-message': originalCapabilities.includes('pin-message'),
|
|
102
|
+
'create-poll': originalCapabilities.includes('create-poll'),
|
|
103
|
+
'vote-poll': originalCapabilities.includes('vote-poll'),
|
|
104
|
+
};
|
|
105
|
+
const isCapabilitiesChanged = Object.keys(capabilities).some(k => capabilities[k] !== initialCapabilities[k]);
|
|
106
|
+
|
|
107
|
+
const isDirty = isSlowModeChanged || isKeywordsChanged || isCapabilitiesChanged;
|
|
108
|
+
|
|
109
|
+
const handleAddNewKeyword = () => {
|
|
110
|
+
if (newKeyword.trim()) {
|
|
111
|
+
const keyword = newKeyword.trim().toLowerCase();
|
|
112
|
+
if (!keywords.includes(keyword)) {
|
|
113
|
+
setKeywords(prev => [...prev, keyword]);
|
|
114
|
+
}
|
|
115
|
+
setNewKeyword('');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
120
|
+
if (e.key === 'Enter') {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
handleAddNewKeyword();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handeRemoveKeyword = (kw: string) => {
|
|
127
|
+
setKeywords(prev => prev.filter(k => k !== kw));
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSave = async () => {
|
|
131
|
+
if (!channel) return;
|
|
132
|
+
setIsSaving(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
try {
|
|
135
|
+
const dataUpdates: any = {};
|
|
136
|
+
let capabilitiesArray: string[] | null = null;
|
|
137
|
+
|
|
138
|
+
if (isSlowModeChanged) {
|
|
139
|
+
dataUpdates.member_message_cooldown = slowMode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (isKeywordsChanged) {
|
|
143
|
+
dataUpdates.filter_words = keywords;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (isCapabilitiesChanged) {
|
|
147
|
+
const controlledKeys = Object.keys(capabilities);
|
|
148
|
+
const originalCaps = (channel.data?.member_capabilities as string[]) || [];
|
|
149
|
+
|
|
150
|
+
// Preserve unmanaged original capabilities (e.g. create-call, join-call)
|
|
151
|
+
const unmanagedCaps = originalCaps.filter(c => !controlledKeys.includes(c));
|
|
152
|
+
|
|
153
|
+
// Extract managed capabilities that are currently enabled (true)
|
|
154
|
+
const managedEnabledCaps = controlledKeys.filter(k => capabilities[k as keyof typeof capabilities]);
|
|
155
|
+
|
|
156
|
+
// Merge into the final payload array
|
|
157
|
+
capabilitiesArray = [...unmanagedCaps, ...managedEnabledCaps];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (Object.keys(dataUpdates).length > 0 || capabilitiesArray !== null) {
|
|
161
|
+
const payload: any = {};
|
|
162
|
+
|
|
163
|
+
if (Object.keys(dataUpdates).length > 0) {
|
|
164
|
+
payload.data = dataUpdates;
|
|
165
|
+
if (channel.data) Object.assign(channel.data, dataUpdates);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (capabilitiesArray !== null) {
|
|
169
|
+
payload.capabilities = capabilitiesArray;
|
|
170
|
+
if (channel.data) {
|
|
171
|
+
// Local fallback naming to keep UI synchronous
|
|
172
|
+
channel.data.member_capabilities = capabilitiesArray;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Use _update instead of update to safely construct root-level payloads
|
|
177
|
+
await (channel as any)._update(payload);
|
|
178
|
+
}
|
|
179
|
+
onClose();
|
|
180
|
+
} catch (err: any) {
|
|
181
|
+
setError(err?.message || 'Failed to update settings');
|
|
182
|
+
} finally {
|
|
183
|
+
setIsSaving(false);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// We do NOT return null based on !isOpen so the sliding CSS transition is preserved.
|
|
188
|
+
return (
|
|
189
|
+
<Panel isOpen={isOpen} onClose={onClose} title={title} className="ermis-settings-panel">
|
|
190
|
+
|
|
191
|
+
{/*
|
|
192
|
+
This wrapper creates a neat scrollable area with subtle gray background
|
|
193
|
+
which makes white cards pop out smoothly.
|
|
194
|
+
*/}
|
|
195
|
+
<div
|
|
196
|
+
className="ermis-settings-panel__body"
|
|
197
|
+
style={{
|
|
198
|
+
flex: 1,
|
|
199
|
+
minHeight: 0,
|
|
200
|
+
padding: '16px',
|
|
201
|
+
overflowY: 'auto',
|
|
202
|
+
display: 'flex',
|
|
203
|
+
flexDirection: 'column',
|
|
204
|
+
gap: '16px',
|
|
205
|
+
background: 'var(--ermis-bg-secondary)'
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
|
|
209
|
+
{/* Section 1: Permissions */}
|
|
210
|
+
<section
|
|
211
|
+
className="ermis-settings-panel__section"
|
|
212
|
+
style={{
|
|
213
|
+
background: 'var(--ermis-bg-primary)',
|
|
214
|
+
padding: '16px',
|
|
215
|
+
borderRadius: '12px',
|
|
216
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
|
217
|
+
border: '1px solid var(--ermis-border-color)'
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px', gap: '8px' }}>
|
|
221
|
+
<div style={{ background: 'var(--ermis-color-primary-light)', padding: '4px', borderRadius: '8px', color: 'var(--ermis-color-primary)' }}>
|
|
222
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
223
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
224
|
+
</svg>
|
|
225
|
+
</div>
|
|
226
|
+
<h4 style={{ fontSize: '14px', color: 'var(--ermis-text-primary)', fontWeight: 600, margin: 0 }}>
|
|
227
|
+
Member Permissions
|
|
228
|
+
</h4>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div className="ermis-settings-panel__field" style={{ marginBottom: '16px' }}>
|
|
232
|
+
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, fontSize: '12px', color: 'var(--ermis-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
|
233
|
+
Slow Mode
|
|
234
|
+
</label>
|
|
235
|
+
<div style={{ position: 'relative' }}>
|
|
236
|
+
<select
|
|
237
|
+
value={slowMode}
|
|
238
|
+
onChange={e => setSlowMode(Number(e.target.value))}
|
|
239
|
+
style={{
|
|
240
|
+
width: '100%',
|
|
241
|
+
padding: '10px 12px',
|
|
242
|
+
borderRadius: '8px',
|
|
243
|
+
border: '1px solid var(--ermis-border-color)',
|
|
244
|
+
background: 'var(--ermis-bg-secondary)',
|
|
245
|
+
color: 'var(--ermis-text-primary)',
|
|
246
|
+
fontSize: '14px',
|
|
247
|
+
fontWeight: 500,
|
|
248
|
+
appearance: 'none',
|
|
249
|
+
outline: 'none',
|
|
250
|
+
cursor: isSaving ? 'not-allowed' : 'pointer',
|
|
251
|
+
transition: 'border-color 0.2s'
|
|
252
|
+
}}
|
|
253
|
+
disabled={isSaving}
|
|
254
|
+
>
|
|
255
|
+
{slowModeOptions.map(opt => (
|
|
256
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
257
|
+
))}
|
|
258
|
+
</select>
|
|
259
|
+
<div style={{ position: 'absolute', right: '14px', top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: 'var(--ermis-text-secondary)' }}>
|
|
260
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
261
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
262
|
+
</svg>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div className="ermis-settings-panel__toggles" style={{ display: 'flex', flexDirection: 'column' }}>
|
|
268
|
+
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, fontSize: '12px', color: 'var(--ermis-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
|
269
|
+
Capabilities
|
|
270
|
+
</label>
|
|
271
|
+
<div style={{ background: 'var(--ermis-bg-secondary)', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--ermis-border-color)' }}>
|
|
272
|
+
{Object.entries(capabilities).map(([key, value], index, arr) => (
|
|
273
|
+
<div
|
|
274
|
+
key={key}
|
|
275
|
+
style={{
|
|
276
|
+
display: 'flex',
|
|
277
|
+
alignItems: 'center',
|
|
278
|
+
justifyContent: 'space-between',
|
|
279
|
+
padding: '10px 14px',
|
|
280
|
+
borderBottom: index < arr.length - 1 ? '1px solid var(--ermis-border-color)' : 'none'
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<span style={{ fontSize: '14px', fontWeight: 500, color: 'var(--ermis-text-primary)' }}>
|
|
284
|
+
{key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
|
285
|
+
</span>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
role="switch"
|
|
289
|
+
aria-checked={value}
|
|
290
|
+
className={`ermis-channel-info__edit-toggle ${value ? 'ermis-channel-info__edit-toggle--on' : ''}`}
|
|
291
|
+
onClick={() => toggleCapability(key)}
|
|
292
|
+
disabled={isSaving}
|
|
293
|
+
style={{ transform: 'scale(0.85)', transformOrigin: 'right center' }}
|
|
294
|
+
>
|
|
295
|
+
<span className="ermis-channel-info__edit-toggle-thumb" />
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
))}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</section>
|
|
302
|
+
|
|
303
|
+
{/* Section 2: Content Moderation */}
|
|
304
|
+
<section
|
|
305
|
+
className="ermis-settings-panel__section"
|
|
306
|
+
style={{
|
|
307
|
+
background: 'var(--ermis-bg-primary)',
|
|
308
|
+
padding: '16px',
|
|
309
|
+
borderRadius: '12px',
|
|
310
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
|
311
|
+
border: '1px solid var(--ermis-border-color)'
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px', gap: '8px' }}>
|
|
315
|
+
<div style={{ background: 'var(--ermis-color-danger-light)', padding: '4px', borderRadius: '8px', color: 'var(--ermis-color-danger)' }}>
|
|
316
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
317
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
|
318
|
+
<line x1="12" y1="9" x2="12" y2="13"></line>
|
|
319
|
+
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
|
320
|
+
</svg>
|
|
321
|
+
</div>
|
|
322
|
+
<h4 style={{ fontSize: '14px', color: 'var(--ermis-text-primary)', fontWeight: 600, margin: 0 }}>
|
|
323
|
+
Content Moderation
|
|
324
|
+
</h4>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div className="ermis-settings-panel__field" style={{ marginBottom: '16px' }}>
|
|
328
|
+
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, fontSize: '12px', color: 'var(--ermis-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
|
329
|
+
Keyword Filtering
|
|
330
|
+
</label>
|
|
331
|
+
<div style={{ display: 'flex', gap: '8px', position: 'relative' }}>
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
value={newKeyword}
|
|
335
|
+
onChange={e => setNewKeyword(e.target.value)}
|
|
336
|
+
onKeyDown={handleKeyDown}
|
|
337
|
+
placeholder="Type keyword and press Enter or Add..."
|
|
338
|
+
style={{
|
|
339
|
+
flex: 1,
|
|
340
|
+
minWidth: 0,
|
|
341
|
+
padding: '10px 12px',
|
|
342
|
+
borderRadius: '8px',
|
|
343
|
+
border: '1px solid var(--ermis-border-color)',
|
|
344
|
+
background: 'var(--ermis-bg-secondary)',
|
|
345
|
+
color: 'var(--ermis-text-primary)',
|
|
346
|
+
fontSize: '14px',
|
|
347
|
+
outline: 'none'
|
|
348
|
+
}}
|
|
349
|
+
disabled={isSaving}
|
|
350
|
+
/>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
onClick={handleAddNewKeyword}
|
|
354
|
+
disabled={isSaving || !newKeyword.trim()}
|
|
355
|
+
style={{
|
|
356
|
+
padding: '0 12px',
|
|
357
|
+
borderRadius: '8px',
|
|
358
|
+
background: 'var(--ermis-color-primary-light)',
|
|
359
|
+
color: 'var(--ermis-color-primary)',
|
|
360
|
+
border: 'none',
|
|
361
|
+
fontWeight: 600,
|
|
362
|
+
fontSize: '13px',
|
|
363
|
+
cursor: isSaving || !newKeyword.trim() ? 'not-allowed' : 'pointer',
|
|
364
|
+
opacity: isSaving || !newKeyword.trim() ? 0.6 : 1,
|
|
365
|
+
transition: 'opacity 0.2s',
|
|
366
|
+
display: 'flex',
|
|
367
|
+
alignItems: 'center',
|
|
368
|
+
justifyContent: 'center'
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
Add
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', minHeight: '32px' }}>
|
|
377
|
+
{keywords.map(kw => (
|
|
378
|
+
<span
|
|
379
|
+
key={kw}
|
|
380
|
+
style={{
|
|
381
|
+
display: 'inline-flex',
|
|
382
|
+
alignItems: 'center',
|
|
383
|
+
gap: '4px',
|
|
384
|
+
padding: '3px 8px',
|
|
385
|
+
borderRadius: '16px',
|
|
386
|
+
background: 'var(--ermis-color-danger-light)',
|
|
387
|
+
color: 'var(--ermis-color-danger)',
|
|
388
|
+
border: '1px solid rgba(239, 68, 68, 0.2)',
|
|
389
|
+
fontSize: '12px',
|
|
390
|
+
fontWeight: 600
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
{kw}
|
|
394
|
+
<button
|
|
395
|
+
onClick={() => handeRemoveKeyword(kw)}
|
|
396
|
+
style={{
|
|
397
|
+
display: 'flex',
|
|
398
|
+
alignItems: 'center',
|
|
399
|
+
justifyContent: 'center',
|
|
400
|
+
background: 'transparent',
|
|
401
|
+
border: 'none',
|
|
402
|
+
cursor: 'pointer',
|
|
403
|
+
padding: '2px',
|
|
404
|
+
color: 'inherit',
|
|
405
|
+
opacity: 0.8,
|
|
406
|
+
}}
|
|
407
|
+
disabled={isSaving}
|
|
408
|
+
aria-label="Remove keyword"
|
|
409
|
+
>
|
|
410
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
411
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
412
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
413
|
+
</svg>
|
|
414
|
+
</button>
|
|
415
|
+
</span>
|
|
416
|
+
))}
|
|
417
|
+
{keywords.length === 0 && (
|
|
418
|
+
<span style={{ fontSize: '14px', color: 'var(--ermis-text-secondary)', padding: '6px 0', fontStyle: 'italic' }}>
|
|
419
|
+
No blacklisted keywords added.
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
</section>
|
|
424
|
+
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
{/* Footer Area */}
|
|
428
|
+
<div
|
|
429
|
+
className="ermis-settings-panel__footer"
|
|
430
|
+
style={{
|
|
431
|
+
flexShrink: 0,
|
|
432
|
+
padding: '12px 16px',
|
|
433
|
+
background: 'var(--ermis-bg-primary)',
|
|
434
|
+
borderTop: '1px solid var(--ermis-border-color)',
|
|
435
|
+
display: 'flex',
|
|
436
|
+
flexDirection: 'column',
|
|
437
|
+
gap: '8px'
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{error && (
|
|
441
|
+
<div style={{ color: 'var(--ermis-color-danger)', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 500 }}>
|
|
442
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
|
|
443
|
+
{error}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
<button
|
|
447
|
+
onClick={handleSave}
|
|
448
|
+
disabled={isSaving || !isDirty}
|
|
449
|
+
style={{
|
|
450
|
+
width: '100%',
|
|
451
|
+
padding: '10px',
|
|
452
|
+
borderRadius: '8px',
|
|
453
|
+
background: 'var(--ermis-color-primary, #006eff)',
|
|
454
|
+
color: '#ffffff',
|
|
455
|
+
border: 'none',
|
|
456
|
+
fontSize: '14px',
|
|
457
|
+
fontWeight: 600,
|
|
458
|
+
cursor: (isSaving || !isDirty) ? 'not-allowed' : 'pointer',
|
|
459
|
+
opacity: (isSaving || !isDirty) ? 0.6 : 1,
|
|
460
|
+
transition: 'all 0.2s ease',
|
|
461
|
+
boxShadow: (isSaving || !isDirty) ? 'none' : '0 4px 12px rgba(0, 110, 255, 0.2)'
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
{isSaving ? (
|
|
465
|
+
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
|
|
466
|
+
<svg className="ermis-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
|
|
467
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
|
468
|
+
</svg>
|
|
469
|
+
Saving...
|
|
470
|
+
</span>
|
|
471
|
+
) : 'Save Updates'}
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
</Panel>
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
ChannelSettingsPanel.displayName = 'ChannelSettingsPanel';
|