@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.
Files changed (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. 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';