Sasha commited on
Commit
fdc7871
·
1 Parent(s): 91ae573

refactor: modularize client App.jsx tab components and update Netlify CSP

Browse files
client/src/App.jsx CHANGED
@@ -29,7 +29,12 @@ import {
29
  Legend,
30
  Filler
31
  } from 'chart.js';
32
- import { Line, Radar } from 'react-chartjs-2';
 
 
 
 
 
33
 
34
  // Register Chart.js components
35
  ChartJS.register(
@@ -48,239 +53,7 @@ ChartJS.register(
48
  const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
49
  const TWITCH_CHANNEL = 'winx_prinx';
50
 
51
- // 3D Galaxy Chatter Orbit Visualization
52
- function ChatterOrbit({ chatters }) {
53
- const canvasRef = useRef(null);
54
- const containerRef = useRef(null);
55
- const particlesRef = useRef(new Map());
56
- const avatarRef = useRef(null);
57
-
58
- useEffect(() => {
59
- // Load Avatar via backend proxy (avoids CORS issues with decapi.me)
60
- if (!avatarRef.current) {
61
- const img = new Image();
62
- img.src = `${API_BASE}/api/avatar/${TWITCH_CHANNEL}`;
63
- img.onload = () => { avatarRef.current = img; };
64
- img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed');
65
- }
66
- }, []);
67
-
68
- useEffect(() => {
69
- const safeChatters = Array.isArray(chatters) ? chatters : [];
70
-
71
- safeChatters.forEach(c => {
72
- const username = c.username || c.display_name || 'unknown';
73
- if (!particlesRef.current.has(username)) {
74
- const isLurker = !c.has_chatted && !(c.message_count > 0);
75
- // All particles go on the ring — tight band around ring radius ±8%
76
- const ringNoise = (Math.random() - 0.5) * 0.16;
77
- const radiusNorm = 1.0 + ringNoise;
78
-
79
- particlesRef.current.set(username, {
80
- username,
81
- displayName: c.display_name || username,
82
- angle: Math.random() * Math.PI * 2,
83
- radiusNorm,
84
- speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1),
85
- isMod: c.is_mod,
86
- isSub: c.is_sub,
87
- isLurker,
88
- messageCount: c.message_count || 0,
89
- isStreamer: username.toLowerCase() === 'winx_prinx',
90
- });
91
- } else {
92
- const p = particlesRef.current.get(username);
93
- const isLurker = !c.has_chatted && !(c.message_count > 0);
94
- p.isLurker = isLurker;
95
- p.messageCount = c.message_count || p.messageCount;
96
- p.isMod = c.is_mod || p.isMod;
97
- p.isSub = c.is_sub || p.isSub;
98
- }
99
- });
100
- }, [chatters]);
101
-
102
- useEffect(() => {
103
- const canvas = canvasRef.current;
104
- const container = containerRef.current;
105
- if (!canvas || !container) return;
106
- const ctx = canvas.getContext('2d');
107
- let animationId;
108
-
109
- const HEIGHT = 320;
110
-
111
- const resize = () => {
112
- const dpr = window.devicePixelRatio || 1;
113
- const rect = container.getBoundingClientRect();
114
- canvas.width = rect.width * dpr;
115
- canvas.height = HEIGHT * dpr;
116
- canvas.style.width = `${rect.width}px`;
117
- canvas.style.height = `${HEIGHT}px`;
118
- ctx.scale(dpr, dpr);
119
- };
120
- resize();
121
- window.addEventListener('resize', resize);
122
-
123
- let hoverName = null;
124
- let hoverX = 0;
125
- let hoverY = 0;
126
- let mouseX = -9999;
127
- let mouseY = -9999;
128
-
129
- const handleMouseMove = (e) => {
130
- const rect = canvas.getBoundingClientRect();
131
- mouseX = e.clientX - rect.left;
132
- mouseY = e.clientY - rect.top;
133
- };
134
- const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; };
135
- canvas.addEventListener('mousemove', handleMouseMove);
136
- canvas.addEventListener('mouseleave', handleMouseLeave);
137
-
138
- const animate = () => {
139
- const width = canvas.width / (window.devicePixelRatio || 1);
140
- const height = HEIGHT;
141
- const cx = width / 2;
142
- const cy = height / 2;
143
-
144
- // Ring fills most of the canvas
145
- const RX = width * 0.44;
146
- const RY = height * 0.36;
147
- const TILT = RY / RX;
148
-
149
- // Solid near-black background
150
- ctx.fillStyle = '#08080f';
151
- ctx.fillRect(0, 0, width, height);
152
-
153
- const particles = Array.from(particlesRef.current.values());
154
-
155
- hoverName = null;
156
-
157
- // Update positions
158
- particles.forEach(p => {
159
- p.angle += p.speed;
160
- const r = p.radiusNorm * RX;
161
- p.x = cx + Math.cos(p.angle) * r;
162
- p.y = cy + Math.sin(p.angle) * r * TILT;
163
- p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front
164
-
165
- if (p.isStreamer) {
166
- p.baseSize = 5;
167
- p.color = '#A370F7';
168
- } else if (p.isLurker) {
169
- p.baseSize = 1;
170
- p.color = null;
171
- } else {
172
- p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2);
173
- p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8';
174
- }
175
- p.drawSize = p.baseSize;
176
- });
177
-
178
- // Sort back-to-front
179
- particles.sort((a, b) => a.depth - b.depth);
180
-
181
- // Draw particles
182
- particles.forEach(p => {
183
- const alpha = p.isLurker
184
- ? 0.15 + p.depth * 0.45
185
- : 0.5 + p.depth * 0.5;
186
-
187
- let color;
188
- if (p.isLurker) {
189
- const brightness = Math.round(160 + p.depth * 60);
190
- color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`;
191
- } else {
192
- color = p.color;
193
- }
194
-
195
- const dist = Math.hypot(mouseX - p.x, mouseY - p.y);
196
- if (dist < p.drawSize + 6) {
197
- hoverName = p.displayName;
198
- hoverX = p.x;
199
- hoverY = p.y;
200
- p.drawSize = Math.max(p.drawSize * 2.5, 5);
201
- }
202
-
203
- ctx.globalAlpha = p.isLurker ? 1 : alpha;
204
- ctx.shadowBlur = 0;
205
-
206
- if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) {
207
- ctx.shadowBlur = 6;
208
- ctx.shadowColor = color;
209
- }
210
-
211
- ctx.beginPath();
212
- ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2);
213
- ctx.fillStyle = color;
214
- ctx.fill();
215
- });
216
- ctx.globalAlpha = 1;
217
- ctx.shadowBlur = 0;
218
-
219
- // Draw Avatar on top
220
- ctx.save();
221
- ctx.beginPath();
222
- ctx.arc(cx, cy, 26, 0, Math.PI * 2);
223
- ctx.closePath();
224
- ctx.shadowColor = '#9147ff';
225
- ctx.shadowBlur = 24;
226
- ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)';
227
- ctx.lineWidth = 2.5;
228
- ctx.stroke();
229
- ctx.shadowBlur = 0;
230
- ctx.clip();
231
- if (avatarRef.current) {
232
- ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52);
233
- } else {
234
- ctx.fillStyle = '#6441A5';
235
- ctx.fill();
236
- }
237
- ctx.restore();
238
-
239
- // Hover tooltip
240
- if (hoverName) {
241
- ctx.shadowBlur = 0;
242
- ctx.globalAlpha = 1;
243
- ctx.font = 'bold 11px Inter, sans-serif';
244
- const tw = ctx.measureText(hoverName).width;
245
- const tx = Math.min(hoverX + 12, width - tw - 20);
246
- const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30;
247
- ctx.fillStyle = 'rgba(18,18,24,0.92)';
248
- ctx.beginPath();
249
- if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5);
250
- else ctx.rect(tx - 4, ty, tw + 16, 22);
251
- ctx.fill();
252
- ctx.strokeStyle = 'rgba(145,71,255,0.5)';
253
- ctx.lineWidth = 1;
254
- ctx.stroke();
255
- ctx.fillStyle = '#efeff1';
256
- ctx.fillText(hoverName, tx + 4, ty + 15);
257
- }
258
-
259
- animationId = requestAnimationFrame(animate);
260
- };
261
-
262
- animate();
263
-
264
- return () => {
265
- cancelAnimationFrame(animationId);
266
- canvas.removeEventListener('mousemove', handleMouseMove);
267
- canvas.removeEventListener('mouseleave', handleMouseLeave);
268
- window.removeEventListener('resize', resize);
269
- };
270
- }, []);
271
-
272
- return (
273
- <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', width: '100%', position: 'relative' }}>
274
- <canvas ref={canvasRef} style={{ borderRadius: '8px', display: 'block', width: '100%' }} />
275
- <div style={{ position: 'absolute', bottom: '10px', right: '12px', fontSize: '11px', color: 'rgba(180,180,200,0.7)', display: 'flex', gap: '14px' }}>
276
- <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: 'rgba(200,200,220,0.5)', borderRadius: '50%', marginRight: '4px'}}></span>Зрители</span>
277
- <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#c8c8d8', borderRadius: '50%', marginRight: '4px'}}></span>Чатеры</span>
278
- <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#FF007F', borderRadius: '50%', marginRight: '4px'}}></span>Сабы</span>
279
- <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#00F5D4', borderRadius: '50%', marginRight: '4px'}}></span>Модеры</span>
280
- </div>
281
- </div>
282
- );
283
- }
284
 
285
  export default function App() {
286
  const [activeTab, setActiveTab] = useState('overview');
@@ -893,1063 +666,72 @@ export default function App() {
893
  </div>
894
  ) : (
895
  <>
896
- {/* OVERVIEW TAB */}
897
  {activeTab === 'overview' && (
898
- <div className="tab-content">
899
- {/* Stats row */}
900
- <div className="grid-4col" style={{ marginBottom: '2rem' }}>
901
- <div className="stat-card" style={{ '--accent-color': 'var(--color-brand)' }}>
902
- <div className="stat-card-inner">
903
- <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
904
- <div className="stat-label">Сообщений в чате</div>
905
- <div className="stat-value">{statsSummary.messages.toLocaleString()}</div>
906
- </div>
907
- <div className="stat-icon-wrapper"><MessageSquare size={22} style={{ color: 'var(--color-brand)' }} /></div>
908
- </div>
909
- </div>
910
- <div className="stat-card" style={{ '--accent-color': 'var(--color-text-accent)' }}>
911
- <div className="stat-card-inner">
912
- <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
913
- <div className="stat-label">Уникальных зрителей</div>
914
- <div className="stat-value">{statsSummary.uniqueChatters.toLocaleString()}</div>
915
- </div>
916
- <div className="stat-icon-wrapper"><Users size={22} style={{ color: 'var(--color-text-accent)' }} /></div>
917
- </div>
918
- </div>
919
- <div className="stat-card" style={{ '--accent-color': '#ff007f' }}>
920
- <div className="stat-card-inner">
921
- <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
922
- <div className="stat-label">Самый активный чатер</div>
923
- <div
924
- className="stat-value"
925
- title={statsSummary.mostActiveChatter || ''}
926
- style={{
927
- fontSize: (statsSummary.mostActiveChatter || '').length > 15 ? '1.05rem' : '1.25rem',
928
- letterSpacing: '-0.02em',
929
- overflow: 'hidden',
930
- textOverflow: 'ellipsis',
931
- whiteSpace: 'nowrap'
932
- }}
933
- >
934
- {statsSummary.mostActiveChatter || '—'}
935
- </div>
936
- </div>
937
- <div className="stat-icon-wrapper"><Users size={22} style={{ color: '#ff007f' }} /></div>
938
- </div>
939
- </div>
940
- <div className="stat-card" style={{ '--accent-color': '#00f5d4' }}>
941
- <div className="stat-card-inner">
942
- <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
943
- <div className="stat-label">Распознано слов (голос)</div>
944
- <div className="stat-value">{statsSummary.voiceWordsCount.toLocaleString()}</div>
945
- </div>
946
- <div className="stat-icon-wrapper"><Mic size={22} style={{ color: '#00f5d4' }} /></div>
947
- </div>
948
- </div>
949
- </div>
950
-
951
- <div className="grid-2col" style={{ gridTemplateColumns: '2fr 1fr' }}>
952
- {/* Activity graph */}
953
- <div className="panel">
954
- <div className="panel-header">
955
- <h3 className="panel-title"><Activity size={20} /> Активность чата</h3>
956
- <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
957
- <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>выберите конкретный стрим для графика</span>
958
- {activityData.length > 0 && (
959
- <button
960
- className="btn btn-secondary"
961
- style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
962
- onClick={startGraphRace}
963
- disabled={isRacing}
964
- >
965
- <Activity size={12} className={isRacing ? "pulse" : ""} /> {isRacing ? 'Гонка идет...' : 'Запустить гонку'}
966
- </button>
967
- )}
968
- </div>
969
- </div>
970
- {selectedStreamId === 'all' ? (
971
- <div className="status-msg" style={{ height: '300px' }}>
972
- <BarChart2 size={36} className="status-msg-icon" />
973
- <p>Для просмотра временного графика выберите конкретный стрим в верхнем меню</p>
974
- </div>
975
- ) : activityData.length === 0 ? (
976
- <div className="status-msg" style={{ height: '300px' }}>
977
- <MessageSquare size={36} className="status-msg-icon" />
978
- <p>В этом стриме пока не было сообщений</p>
979
- </div>
980
- ) : (
981
- <div className="chart-container">
982
- <Line data={chartDataConfig} options={chartOptions} />
983
- </div>
984
- )}
985
- </div>
986
-
987
- {/* Quick Words list */}
988
- <div className="panel">
989
- <div className="panel-header">
990
- <h3 className="panel-title">
991
- <Mic size={18} style={{ color: 'var(--color-text-accent)' }} /> Голос стримера (Топ)
992
- </h3>
993
- </div>
994
- {voiceWords.length === 0 ? (
995
- <div className="status-msg" style={{ height: '300px', padding: '1rem' }}>
996
- <Mic size={28} className="status-msg-icon" />
997
- <p>Слова пока не распознаны. Запустите local_worker на ПК.</p>
998
- </div>
999
- ) : (
1000
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxHeight: '320px', overflowY: 'auto', paddingRight: '4px' }}>
1001
- {voiceWords.slice(0, 7).map((w, idx) => (
1002
- <div key={idx} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem', backgroundColor: 'var(--color-bg-base)', border: '1px solid var(--color-border)', borderRadius: '6px', alignItems: 'center' }}>
1003
- <span style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>#{idx+1} {w.word}</span>
1004
- <span className="badge badge-streamer">{w.word_count} раз</span>
1005
- </div>
1006
- ))}
1007
- </div>
1008
- )}
1009
- </div>
1010
- </div>
1011
- </div>
1012
  )}
1013
 
1014
- {/* CHATTERS TAB */}
1015
  {activeTab === 'chatters' && (
1016
- <div className="grid-2col tab-content" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
1017
- <div className="panel">
1018
- <div className="panel-header">
1019
- <h3 className="panel-title"><Users size={20} /> Орбита зрителей</h3>
1020
- </div>
1021
- <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
1022
- <ChatterOrbit chatters={chatters} />
1023
- </div>
1024
- </div>
1025
-
1026
- <div className="panel">
1027
- <div className="panel-header">
1028
- <h3 className="panel-title"><Users size={20} /> Лидеры чата по активности</h3>
1029
- <div className="search-container">
1030
- <Search size={16} className="search-icon" />
1031
- <input
1032
- type="text"
1033
- className="search-input"
1034
- placeholder="Поиск зрителя..."
1035
- value={chattersSearch}
1036
- onChange={(e) => setChattersSearch(e.target.value)}
1037
- />
1038
- </div>
1039
- </div>
1040
-
1041
- {chatters.length === 0 ? (
1042
- <div className="status-msg">
1043
- <Users size={40} className="status-msg-icon" />
1044
- <p>Нет данных о зрителях для этого периода</p>
1045
- </div>
1046
- ) : (
1047
- <div className="table-wrapper">
1048
- <table className="data-table">
1049
- <thead>
1050
- <tr>
1051
- <th>Место</th>
1052
- <th>Никнейм</th>
1053
- <th>Роли</th>
1054
- <th>Сообщений</th>
1055
- </tr>
1056
- </thead>
1057
- <tbody>
1058
- {chatters
1059
- .filter(c => {
1060
- const disp = c.display_name || c.username || '';
1061
- const user = c.username || '';
1062
- return disp.toLowerCase().includes(chattersSearch.toLowerCase()) ||
1063
- user.toLowerCase().includes(chattersSearch.toLowerCase());
1064
- })
1065
- .map((c, idx) => (
1066
- <tr key={idx} className="chatter-row">
1067
- <td style={{ fontWeight: 700, width: '80px', color: idx < 3 ? 'var(--color-brand)' : 'var(--color-text-muted)' }}>
1068
- #{idx + 1}
1069
- </td>
1070
- <td style={{ fontWeight: 600 }}>{c.display_name || c.username || '—'}</td>
1071
- <td>
1072
- <div style={{ display: 'flex', gap: '0.4rem' }}>
1073
- {(c.username || '').toLowerCase() === TWITCH_CHANNEL && (
1074
- <span className="badge badge-streamer">Стример</span>
1075
- )}
1076
- {c.is_mod ? <span className="badge badge-mod">Мод</span> : null}
1077
- {c.is_vip && !c.is_mod ? <span className="badge badge-vip">VIP</span> : null}
1078
- {c.is_sub && !c.is_mod && !c.is_vip ? <span className="badge badge-sub">Саб</span> : null}
1079
- </div>
1080
- </td>
1081
- <td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}>
1082
- {c.message_count.toLocaleString()}
1083
- </td>
1084
- </tr>
1085
- ))}
1086
- </tbody>
1087
- </table>
1088
- </div>
1089
- )}
1090
- </div>
1091
- </div>
1092
  )}
1093
 
1094
- {/* WORDS TAB */}
1095
  {activeTab === 'words' && (
1096
- <div className="panel tab-content">
1097
- <div className="panel-header">
1098
- <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3>
1099
-
1100
- <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}>
1101
- <button
1102
- className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`}
1103
- onClick={() => setWordType('voice')}
1104
- style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
1105
- >
1106
- <Volume2 size={14} /> Из Голоса (Whisper)
1107
- </button>
1108
- <button
1109
- className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`}
1110
- onClick={() => setWordType('chat')}
1111
- style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
1112
- >
1113
- <MessageSquare size={14} /> Из Чата (Текстом)
1114
- </button>
1115
- </div>
1116
- </div>
1117
-
1118
- <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}>
1119
- {wordType === 'voice'
1120
- ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).'
1121
- : 'Слова, которые зрители написали в текстовый чат Twitch.'}
1122
- </p>
1123
-
1124
- {(() => {
1125
- const currentWordsList = wordType === 'voice'
1126
- ? (Array.isArray(voiceWords) ? voiceWords : [])
1127
- : (Array.isArray(chatWords) ? chatWords : []);
1128
- if (currentWordsList.length === 0) {
1129
- return (
1130
- <div className="status-msg">
1131
- <Mic size={40} className="status-msg-icon" />
1132
- <p>Нет собранных слов для выбранного стрима</p>
1133
- </div>
1134
- );
1135
- }
1136
- const counts = currentWordsList.map(x => x.word_count);
1137
- const maxCount = Math.max(...counts, 1);
1138
- const minCount = Math.min(...counts, 1);
1139
-
1140
- // Helper for Russian pluralization
1141
- const getRussianPlural = (count) => {
1142
- const lastDigit = count % 10;
1143
- const lastTwoDigits = count % 100;
1144
- if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
1145
- return 'раз';
1146
- }
1147
- if (lastDigit === 1) {
1148
- return 'раз';
1149
- }
1150
- if (lastDigit >= 2 && lastDigit <= 4) {
1151
- return 'раза';
1152
- }
1153
- return 'раз';
1154
- };
1155
-
1156
- // Color assignment based on word frequency: purple gradient
1157
- const getWordColor = (count) => {
1158
- const scale = maxCount === minCount ? 1 : (count - minCount) / (maxCount - minCount);
1159
- // Scale saturation from 35% (desaturated/white-lavender) to 100% (saturated purple)
1160
- const sat = Math.round(35 + scale * 65);
1161
- // Scale lightness from 92% (light/white-ish) to 60% (vivid deep purple)
1162
- const light = Math.round(92 - scale * 32);
1163
- return `hsl(265, ${sat}%, ${light}%)`;
1164
- };
1165
-
1166
- // Layout calculations with collision avoidance on stretched elliptical spiral
1167
- const placedBoxes = [];
1168
- const laidOutWords = [];
1169
-
1170
- const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
1171
- const stretchX = isMobile ? 1.4 : 2.8;
1172
- const stretchY = 1.0;
1173
- const paddingX = isMobile ? 6 : 12;
1174
- const paddingY = isMobile ? 4 : 8;
1175
- const fontScale = isMobile ? 0.45 : 1.0;
1176
- const remToPx = 16;
1177
-
1178
- const centerGroupThreshold = wordType === 'chat' ? 250 : 500;
1179
- // Identify center group: words with count within threshold of the max count,
1180
- // but ONLY if the top count is >= threshold. Also cap center group size to at most 3 words.
1181
- const centerGroupWords = currentWordsList.filter((w, idx) =>
1182
- idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3)
1183
- );
1184
- const otherWordsList = currentWordsList.filter((w, idx) =>
1185
- !(idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3))
1186
- );
1187
-
1188
- const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1;
1189
- const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1;
1190
- const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count));
1191
-
1192
- // Function to estimate word width factor based on wide/narrow letters
1193
- const getWordWidthFactor = (word) => {
1194
- let factor = 0;
1195
- const wideChars = /[мжшщыюяwm]/i;
1196
- const narrowChars = /[ilj1!|т]/i;
1197
- for (const char of word) {
1198
- if (wideChars.test(char)) {
1199
- factor += 0.75;
1200
- } else if (narrowChars.test(char)) {
1201
- factor += 0.35;
1202
- } else {
1203
- factor += 0.55;
1204
- }
1205
- }
1206
- return factor;
1207
- };
1208
-
1209
- for (let i = 0; i < currentWordsList.length; i++) {
1210
- const w = currentWordsList[i];
1211
- const isCenterGroup = i === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && i < 3);
1212
-
1213
- let fontSize;
1214
- let fontWeight;
1215
- let scaleValue = 0;
1216
-
1217
- // Scale down the entire galaxy if the absolute max frequency is low (e.g. less than 200)
1218
- // If maxCount is 20, galaxyScale is ~0.65. If 200+, it's 1.0.
1219
- const galaxyScale = Math.min(Math.max(maxCount / 200, 0), 1.0) * 0.35 + 0.65;
1220
-
1221
- if (isCenterGroup) {
1222
- // Scale font size within center group: ranges from 4.5rem to 6.2rem
1223
- const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount);
1224
- fontSize = 4.5 + centerScale * 1.7;
1225
- fontWeight = '900';
1226
- scaleValue = centerScale;
1227
- } else {
1228
- // Scale font size within other words: ranges from 1.1rem to 3.2rem
1229
- const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount);
1230
- scaleValue = Math.pow(scale, 1.4);
1231
- fontSize = 1.1 + scaleValue * 2.1;
1232
- fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500');
1233
- }
1234
-
1235
- fontSize = fontSize * fontScale * galaxyScale;
1236
-
1237
- // Precise width calculation based on custom width factors
1238
- const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx;
1239
- const wordHeight = fontSize * 1.1 * remToPx;
1240
-
1241
- let x = 0;
1242
- let y = 0;
1243
-
1244
- // Generate a stable hash for the word to randomize angle and jitter
1245
- let hash = 0;
1246
- for (let j = 0; j < w.word.length; j++) {
1247
- hash = w.word.charCodeAt(j) + ((hash << 5) - hash);
1248
- }
1249
- hash = Math.abs(hash);
1250
-
1251
- // Search for position starting from r = 0.
1252
- // Since center group words are processed first, they cluster tightly around (0,0).
1253
- let found = false;
1254
- const rStep = 1.1;
1255
-
1256
- for (let attempt = 0; attempt < 1200; attempt++) {
1257
- const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI
1258
- const angle = startAngle + attempt * 0.15;
1259
- const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt);
1260
-
1261
- x = r * Math.cos(angle) * stretchX;
1262
- y = r * Math.sin(angle) * stretchY;
1263
-
1264
- // Add coordinate noise/jitter (except for the absolute top word at the center)
1265
- if (r > 0) {
1266
- x += Math.sin(hash * 0.5 + attempt) * 6;
1267
- y += Math.cos(hash * 0.8 + attempt) * 4;
1268
- }
1269
-
1270
- let collision = false;
1271
- for (const box of placedBoxes) {
1272
- const halfW1 = wordWidth / 2;
1273
- const halfH1 = wordHeight / 2;
1274
- const halfW2 = box.w / 2;
1275
- const halfH2 = box.h / 2;
1276
-
1277
- if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) &&
1278
- Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) {
1279
- collision = true;
1280
- break;
1281
- }
1282
- }
1283
-
1284
- if (!collision) {
1285
- found = true;
1286
- break;
1287
- }
1288
- }
1289
-
1290
- placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight });
1291
-
1292
- const color = getWordColor(w.word_count);
1293
-
1294
- // Floating parameters
1295
- const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s
1296
- const delay = -((i * 1.3) % 7); // negative delay to start asynchronously
1297
- const amount = 3 + (i % 4); // 3px to 6px float amount
1298
-
1299
- laidOutWords.push({
1300
- word: w.word,
1301
- count: w.word_count,
1302
- x: Math.round(x),
1303
- y: Math.round(y),
1304
- fontSize: `${fontSize}rem`,
1305
- fontWeight: fontWeight,
1306
- color: color,
1307
- scale: scaleValue,
1308
- isCenterGroup: isCenterGroup,
1309
- duration: `${duration}s`,
1310
- delay: `${delay}s`,
1311
- amount: `${amount}px`
1312
- });
1313
- }
1314
-
1315
- return (
1316
- <div className="word-galaxy-container">
1317
- {laidOutWords.map((w, idx) => (
1318
- <div
1319
- key={idx}
1320
- className="word-galaxy-tag"
1321
- style={{
1322
- '--x': `${w.x}px`,
1323
- '--y': `${w.y}px`,
1324
- '--float-duration': w.duration,
1325
- '--float-delay': w.delay,
1326
- '--float-amount': w.amount,
1327
- fontSize: w.fontSize,
1328
- fontWeight: w.fontWeight,
1329
- color: w.color,
1330
- opacity: 0.95, // High opacity so purple colors are bright and vivid
1331
- zIndex: w.isCenterGroup ? 25 : Math.round(10 + w.scale * 15)
1332
- }}
1333
- >
1334
- {w.word}
1335
- <div className="word-tooltip">
1336
- {w.count} {getRussianPlural(w.count)}
1337
- </div>
1338
- </div>
1339
- ))}
1340
- </div>
1341
- );
1342
- })()}
1343
- </div>
1344
  )}
1345
 
1346
- {/* ADMIN TAB */}
1347
  {activeTab === 'admin' && (
1348
- <div className="tab-content">
1349
- <h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
1350
- <Settings size={24} style={{ color: 'var(--color-brand)' }} /> Панель администратора
1351
- </h2>
1352
-
1353
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
1354
-
1355
- {/* Left: Stream management */}
1356
- <div className="panel" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
1357
- <div className="panel-header">
1358
- <h3 className="panel-title">Управление стримами</h3>
1359
- </div>
1360
-
1361
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
1362
- <label style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Выбрать стрим для редактирования или удаления:</label>
1363
- <select
1364
- className="dark-input"
1365
- value={adminSelectedStreamId}
1366
- onChange={(e) => setAdminSelectedStreamId(e.target.value)}
1367
- >
1368
- <option value="">-- Выберите стрим --</option>
1369
- {streams.map((stream) => (
1370
- <option key={stream.id} value={stream.id}>
1371
- {stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })})
1372
- </option>
1373
- ))}
1374
- </select>
1375
- </div>
1376
-
1377
- {(() => {
1378
- const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
1379
- if (!activeStream) return (
1380
- <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
1381
- Выберите стрим в выпадающем списке выше для выполнения действий.
1382
- </div>
1383
- );
1384
-
1385
- const isLive = !activeStream.end_time;
1386
- const statusLabel =
1387
- activeStream.backfill_status === 'completed' ? 'Импортирован полностью' :
1388
- activeStream.backfill_status === 'pending' ? 'В очереди воркера (ожидание)' :
1389
- activeStream.backfill_status === 'live' ? 'В эфире (live)' : activeStream.backfill_status;
1390
-
1391
- const statusColor =
1392
- activeStream.backfill_status === 'completed' ? 'var(--color-success, #00f2fe)' :
1393
- activeStream.backfill_status === 'pending' ? 'var(--color-warning, #f1c40f)' : 'var(--color-primary)';
1394
-
1395
- return (
1396
- <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
1397
- <div style={{ fontSize: '0.8rem', padding: '0.75rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px solid var(--color-border)' }}>
1398
- <p style={{ margin: '0 0 0.4rem 0' }}><strong>ID сессии:</strong> {activeStream.id}</p>
1399
- <p style={{ margin: '0 0 0.4rem 0' }}><strong>Статус импорта VOD:</strong> <span style={{ color: statusColor, fontWeight: 'bold' }}>{statusLabel}</span></p>
1400
- <p style={{ margin: '0 0 0.4rem 0' }}><strong>Twitch VOD ID:</strong> {activeStream.twitch_vod_id || 'Отсутствует'}</p>
1401
- <p style={{ margin: 0 }}><strong>Начало:</strong> {new Date(activeStream.start_time).toLocaleString('ru-RU')}</p>
1402
- </div>
1403
-
1404
- {/* Edit Metadata */}
1405
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: 'rgba(255,255,255,0.01)', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.03)' }}>
1406
- <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Редактирование названия / категории</h4>
1407
-
1408
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
1409
- <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Название стрима:</label>
1410
- <input
1411
- type="text"
1412
- className="dark-input"
1413
- value={editTitle}
1414
- onChange={(e) => setEditTitle(e.target.value)}
1415
- />
1416
- </div>
1417
-
1418
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
1419
- <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Категория (игра):</label>
1420
- <input
1421
- type="text"
1422
- className="dark-input"
1423
- value={editCategory}
1424
- onChange={(e) => setEditCategory(e.target.value)}
1425
- />
1426
- </div>
1427
-
1428
- <button
1429
- className="btn btn-secondary"
1430
- style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
1431
- disabled={savingMetadata || !editTitle.trim()}
1432
- onClick={async () => {
1433
- setSavingMetadata(true);
1434
- try {
1435
- const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
1436
- method: 'PUT',
1437
- headers: { 'Content-Type': 'application/json' },
1438
- body: JSON.stringify({ title: editTitle, category: editCategory }),
1439
- credentials: 'include'
1440
- });
1441
- const data = await res.json();
1442
- if (data.success) {
1443
- alert('Метаданные стрима успешно обновлены!');
1444
- fetchStreams();
1445
- } else {
1446
- alert(`Ошибка: ${data.error}`);
1447
- }
1448
- } catch (e) {
1449
- alert('Ошибка при обновлении метаданных.');
1450
- } finally {
1451
- setSavingMetadata(false);
1452
- }
1453
- }}
1454
- >
1455
- {savingMetadata ? 'Сохранение...' : 'Сохранить изменения'}
1456
- </button>
1457
- </div>
1458
-
1459
- {/* VOD Actions (Only if VOD is present) */}
1460
- {activeStream.twitch_vod_id && (
1461
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
1462
- <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Действия импорта VOD:</h4>
1463
- <div style={{ display: 'flex', gap: '0.5rem' }}>
1464
- <button
1465
- className="btn btn-secondary"
1466
- style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
1467
- onClick={async () => {
1468
- if (!confirm(`Запустить заполнение пропусков для стрима "${activeStream.title}"? Воркер докачает пропущенный чат и Whisper-речь.`)) return;
1469
- try {
1470
- const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
1471
- method: 'POST',
1472
- headers: { 'Content-Type': 'application/json' },
1473
- body: JSON.stringify({ streamId: activeStream.id, mode: 'gap_fill' }),
1474
- credentials: 'include'
1475
- });
1476
- const data = await res.json();
1477
- if (data.success) {
1478
- alert('Статус сброшен на "ожидание". Воркер скоро начнет дозаполнение!');
1479
- fetchStreams();
1480
- } else {
1481
- alert(`Ошибка: ${data.error}`);
1482
- }
1483
- } catch (e) {
1484
- alert('Ошибка при запуске дозаполнения VOD.');
1485
- }
1486
- }}
1487
- >
1488
- Заполнить пропуски VOD
1489
- </button>
1490
- <button
1491
- className="btn btn-secondary"
1492
- style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
1493
- onClick={async () => {
1494
- if (!confirm(`Внимание: это полностью удалит сохраненные сообщения чата и слова Whisper для стрима "${activeStream.title}" и заново запустит весь импорт VOD с 0-й секунды. Вы уверены?`)) return;
1495
- try {
1496
- const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
1497
- method: 'POST',
1498
- headers: { 'Content-Type': 'application/json' },
1499
- body: JSON.stringify({ streamId: activeStream.id, mode: 'full_rebuild' }),
1500
- credentials: 'include'
1501
- });
1502
- const data = await res.json();
1503
- if (data.success) {
1504
- alert('Данные очищены. Стрим поставлен на полный переимпорт воркером!');
1505
- fetchStreams();
1506
- } else {
1507
- alert(`Ошибка: ${data.error}`);
1508
- }
1509
- } catch (e) {
1510
- alert('Ошибка при запуске переимпорта.');
1511
- }
1512
- }}
1513
- >
1514
- Полный переимпорт VOD
1515
- </button>
1516
- </div>
1517
- </div>
1518
- )}
1519
-
1520
- {/* Delete Button */}
1521
- <div style={{ borderTop: '1px solid var(--color-border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
1522
- <button
1523
- className="btn btn-danger"
1524
- style={{
1525
- width: '100%',
1526
- display: 'flex',
1527
- alignItems: 'center',
1528
- justifyContent: 'center',
1529
- gap: '0.4rem',
1530
- backgroundColor: 'rgba(231, 76, 60, 0.15)',
1531
- color: '#e74c3c',
1532
- border: '1px solid rgba(231, 76, 60, 0.3)',
1533
- borderRadius: '4px',
1534
- cursor: 'pointer',
1535
- padding: '0.6rem'
1536
- }}
1537
- disabled={deletingStream}
1538
- onClick={async () => {
1539
- if (!confirm(`ВНИМАНИЕ: Вы уверены, что хотите полностью УДАЛИТЬ стрим "${activeStream.title}"? Это действие сотрет всю связанную статистику (чат, слова Whisper, модерацию) из базы данных навсегда и безвозвратно!`)) return;
1540
- if (!confirm(`ПОСЛЕДНЕЕ ПРЕДУПРЕЖДЕНИЕ: Вы действительно хотите стереть стрим ID ${activeStream.id} из базы? Восстановление невозможно.`)) return;
1541
-
1542
- setDeletingStream(true);
1543
- try {
1544
- const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
1545
- method: 'DELETE',
1546
- credentials: 'include'
1547
- });
1548
- const data = await res.json();
1549
- if (data.success) {
1550
- alert('Стрим успешно удален из базы данн��х!');
1551
- setAdminSelectedStreamId('');
1552
- fetchStreams();
1553
- } else {
1554
- alert(`Ошибка: ${data.error}`);
1555
- }
1556
- } catch (e) {
1557
- alert('Ошибка при удалении стрима.');
1558
- } finally {
1559
- setDeletingStream(false);
1560
- }
1561
- }}
1562
- >
1563
- <Trash2 size={16} /> Удалить стрим полностью
1564
- </button>
1565
- </div>
1566
- </div>
1567
- );
1568
- })()}
1569
- </div>
1570
-
1571
- {/* Right: Global actions and Stats */}
1572
- <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
1573
-
1574
- {/* Global Actions Panel */}
1575
- <div className="panel">
1576
- <div className="panel-header">
1577
- <h3 className="panel-title">Глобальные действия</h3>
1578
- </div>
1579
- <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
1580
-
1581
- {/* Twitch VOD Sync */}
1582
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '0.75rem', borderBottom: '1px solid var(--color-border)' }}>
1583
- <div>
1584
- <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Синхронизация Twitch VOD</h4>
1585
- <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Запросить последние 20 архивов из Twitch API.</p>
1586
- </div>
1587
- <button
1588
- className="btn btn-secondary"
1589
- style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
1590
- disabled={syncingVods}
1591
- onClick={async () => {
1592
- setSyncingVods(true);
1593
- try {
1594
- const res = await fetch(`${API_BASE}/api/streams/sync-vods`, { method: 'POST', credentials: 'include' });
1595
- const data = await res.json();
1596
- if (data.success) {
1597
- alert(`Успешно импортировано ${data.count} стримов!`);
1598
- fetchStreams();
1599
- } else {
1600
- alert(`Ошибка: ${data.error}`);
1601
- }
1602
- } catch (e) {
1603
- alert('Ошибка при синхронизации VOD.');
1604
- } finally {
1605
- setSyncingVods(false);
1606
- }
1607
- }}
1608
- >
1609
- <RefreshCw size={12} className={syncingVods ? "spin" : ""} /> {syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'}
1610
- </button>
1611
- </div>
1612
-
1613
- {/* Database Cleanup */}
1614
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
1615
- <div>
1616
- <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Очистить пустые стримы</h4>
1617
- <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Удалить сессии с 0 сообщениями и 0 слов.</p>
1618
- </div>
1619
- <button
1620
- className="btn btn-secondary"
1621
- style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
1622
- disabled={cleaningStreams}
1623
- onClick={async () => {
1624
- if (!confirm('Вы де��ствительно хотите удалить все пустые стримы (в которых нет ни сообщений в чате, ни распознанных слов)? Это очистит тестовый мусор из списка.')) return;
1625
- setCleaningStreams(true);
1626
- try {
1627
- const res = await fetch(`${API_BASE}/api/admin/cleanup`, { method: 'POST', credentials: 'include' });
1628
- const data = await res.json();
1629
- if (data.success) {
1630
- alert(`Успешно очищено. Удалено пустых стримов: ${data.count}`);
1631
- fetchStreams();
1632
- fetchAdminStats();
1633
- } else {
1634
- alert(`Ошибка: ${data.error}`);
1635
- }
1636
- } catch (e) {
1637
- alert('Ошибка при очистке стримов.');
1638
- } finally {
1639
- setCleaningStreams(false);
1640
- }
1641
- }}
1642
- >
1643
- Очистить пустые стримы
1644
- </button>
1645
- </div>
1646
-
1647
- </div>
1648
- </div>
1649
-
1650
- {/* System Statistics Panel */}
1651
- <div className="panel">
1652
- <div className="panel-header">
1653
- <h3 className="panel-title">Системная статистика</h3>
1654
- </div>
1655
-
1656
- {loadingAdminStats || !adminStats ? (
1657
- <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
1658
- Загрузка статистики...
1659
- </div>
1660
- ) : (
1661
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', fontSize: '0.8rem' }}>
1662
- <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1663
- <span style={{ color: 'var(--color-text-muted)' }}>Режим базы данных:</span>
1664
- <strong style={{ color: 'var(--color-brand)' }}>{adminStats.dbMode.toUpperCase()}</strong>
1665
- </div>
1666
- {adminStats.dbMode === 'sqlite' && (
1667
- <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1668
- <span style={{ color: 'var(--color-text-muted)' }}>Размер файла SQLite:</span>
1669
- <strong>{adminStats.dbSizeMb} МБ</strong>
1670
- </div>
1671
- )}
1672
- <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1673
- <span style={{ color: 'var(--color-text-muted)' }}>Всего стримов в базе:</span>
1674
- <strong>{adminStats.totalStreams}</strong>
1675
- </div>
1676
- <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1677
- <span style={{ color: 'var(--color-text-muted)' }}>Всего сообщений чата:</span>
1678
- <strong>{adminStats.totalMessages.toLocaleString()}</strong>
1679
- </div>
1680
- <div style={{ display: 'flex', justifyContent: 'space-between' }}>
1681
- <span style={{ color: 'var(--color-text-muted)' }}>Всего голосовых слов:</span>
1682
- <strong>{adminStats.totalVoiceWords.toLocaleString()}</strong>
1683
- </div>
1684
-
1685
- <button
1686
- className="btn btn-secondary"
1687
- style={{ width: '100%', fontSize: '0.75rem', padding: '0.4rem', marginTop: '0.5rem' }}
1688
- onClick={fetchAdminStats}
1689
- >
1690
- Обновить статистику
1691
- </button>
1692
- </div>
1693
- )}
1694
- </div>
1695
-
1696
- </div>
1697
-
1698
- </div>
1699
- </div>
1700
  )}
1701
 
1702
- {/* MODERATOR TAB */}
1703
  {activeTab === 'moderator' && (
1704
- <div className="tab-content">
1705
- {twitchConfigured && !auth.loggedIn ? (
1706
- <div className="login-overlay">
1707
- <div className="login-card">
1708
- <Lock size={48} style={{ color: 'var(--color-brand)', marginBottom: '1.5rem' }} />
1709
- <h2 className="login-title">Доступ Ограничен</h2>
1710
- <p className="login-description">
1711
- Лог действий модераторов и статистика банов/удалений доступны только стримеру и официальным модераторам канала **winx_prinx**.
1712
- Пожалуйста, авторизуйтесь через Twitch для проверки прав.
1713
- </p>
1714
- <button className="btn" onClick={handleLogin}>
1715
- <LogIn size={16} /> Войти через Twitch
1716
- </button>
1717
- </div>
1718
- </div>
1719
- ) : twitchConfigured && (auth.user?.role !== 'streamer' && auth.user?.role !== 'moderator' && auth.user?.role !== 'admin') ? (
1720
- <div className="status-msg">
1721
- <ShieldAlert size={48} className="status-msg-icon" />
1722
- <h2>Недостаточно прав</h2>
1723
- <p>Вы успешно вошли как <strong>{auth.user?.displayName}</strong>, но вы не являетесь модератором канала {TWITCH_CHANNEL}.</p>
1724
- </div>
1725
- ) : (
1726
- <div>
1727
- {modProfiles.length <= 2 && (
1728
- <div style={{ background: 'rgba(145, 70, 255, 0.1)', border: '1px solid rgba(145, 70, 255, 0.3)', borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
1729
- <ShieldAlert size={24} style={{ color: 'var(--color-brand)' }} />
1730
- <div>
1731
- <h4 style={{ margin: '0 0 0.25rem 0', color: 'var(--color-text-main)', fontSize: '0.95rem' }}>Данные накапливаются</h4>
1732
- <p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Информация о модераторах появляется по мере их активности в выбранном стриме.</p>
1733
- </div>
1734
- </div>
1735
- )}
1736
- {/* TOP 3 PODIUM */}
1737
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
1738
- {modProfiles.slice(0, 3).map((mod, idx) => {
1739
- const trophyColor = idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32';
1740
- const glowStyle = {
1741
- border: `1px solid ${trophyColor}40`,
1742
- boxShadow: `0 0 15px ${trophyColor}12`,
1743
- position: 'relative',
1744
- overflow: 'hidden'
1745
- };
1746
- return (
1747
- <div key={idx} className="panel" style={glowStyle}>
1748
- <div style={{ position: 'absolute', top: '-10px', right: '-10px', fontSize: '5rem', opacity: 0.08, color: trophyColor, fontWeight: 900 }}>
1749
- #{idx + 1}
1750
- </div>
1751
- <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
1752
- <div style={{
1753
- width: '44px',
1754
- height: '44px',
1755
- borderRadius: '50%',
1756
- background: 'var(--color-bg-base)',
1757
- border: `2px solid ${trophyColor}`,
1758
- display: 'flex',
1759
- alignItems: 'center',
1760
- justifyContent: 'center',
1761
- fontWeight: 700,
1762
- fontSize: '1.1rem',
1763
- color: trophyColor
1764
- }}>
1765
- {idx === 0 ? '👑' : idx === 1 ? '🥈' : '🥉'}
1766
- </div>
1767
- <div>
1768
- <h4 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>{mod.moderator}</h4>
1769
- <span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
1770
- {idx === 0 ? 'Глава патруля' : idx === 1 ? 'Старший мод' : 'Защитник чата'}
1771
- </span>
1772
- </div>
1773
- </div>
1774
- <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1.25rem', fontSize: '0.8rem' }}>
1775
- <div>
1776
- <div style={{ color: 'var(--color-text-muted)' }}>Действий</div>
1777
- <div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--color-text-accent)' }}>{mod.total_actions}</div>
1778
- </div>
1779
- <div>
1780
- <div style={{ color: 'var(--color-text-muted)' }}>Ср. Реакция</div>
1781
- <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#00F5D4' }}>
1782
- {mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}
1783
- </div>
1784
- </div>
1785
- <div>
1786
- <div style={{ color: 'var(--color-text-muted)' }}>КПД Активности</div>
1787
- <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#A370F7' }}>{mod.scores.activity}%</div>
1788
- </div>
1789
- </div>
1790
- </div>
1791
- );
1792
- })}
1793
- </div>
1794
-
1795
- <div className="grid-2col" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
1796
- {/* Left: Mod List */}
1797
- <div className="panel">
1798
- <div className="panel-header">
1799
- <h3 className="panel-title"><ShieldAlert size={20} /> Рейтинг модераторов</h3>
1800
- </div>
1801
- {modProfiles.length === 0 ? (
1802
- <div className="status-msg">
1803
- <ShieldAlert size={40} className="status-msg-icon" />
1804
- <p>Модераторы еще не совершали действий в этом периоде</p>
1805
- </div>
1806
- ) : (
1807
- <div className="table-wrapper">
1808
- <table className="data-table">
1809
- <thead>
1810
- <tr>
1811
- <th>Ранг</th>
1812
- <th>Никнейм</th>
1813
- <th>Действий</th>
1814
- <th>Ср. Реакция</th>
1815
- </tr>
1816
- </thead>
1817
- <tbody>
1818
- {modProfiles.map((mod, idx) => (
1819
- <tr
1820
- key={idx}
1821
- onClick={() => setSelectedMod(mod)}
1822
- style={{
1823
- cursor: 'pointer',
1824
- background: selectedMod && selectedMod.moderator === mod.moderator ? 'rgba(163, 112, 247, 0.08)' : 'transparent',
1825
- borderLeft: selectedMod && selectedMod.moderator === mod.moderator ? '3px solid var(--color-brand)' : 'none'
1826
- }}
1827
- >
1828
- <td>#{idx + 1}</td>
1829
- <td style={{ fontWeight: 600 }}>{mod.moderator}</td>
1830
- <td>{mod.total_actions}</td>
1831
- <td style={{ color: '#00F5D4', fontWeight: 600 }}>{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}</td>
1832
- </tr>
1833
- ))}
1834
- </tbody>
1835
- </table>
1836
- </div>
1837
- )}
1838
- </div>
1839
-
1840
- {/* Right: Mod Dossier */}
1841
- <div className="panel">
1842
- <div className="panel-header">
1843
- <h3 className="panel-title"><UserCheck size={20} /> Личное досье модератора</h3>
1844
- </div>
1845
-
1846
- {!selectedMod ? (
1847
- <div className="status-msg">
1848
- <UserCheck size={40} className="status-msg-icon" />
1849
- <p>Выберите модератора слева для просмотра досье</p>
1850
- </div>
1851
- ) : (
1852
- <div>
1853
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
1854
- <div>
1855
- <h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-brand)' }}>{selectedMod.moderator}</h3>
1856
- <p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Анализ стиля модерирования и характеристик</p>
1857
- </div>
1858
- </div>
1859
-
1860
- {/* Radar chart */}
1861
- <div style={{ height: '280px', display: 'flex', justifyContent: 'center', marginBottom: '1.25rem' }}>
1862
- <Radar
1863
- data={{
1864
- labels: ['Скорость', 'Активность', 'Жесткость', 'Внимательность'],
1865
- datasets: [{
1866
- data: [
1867
- selectedMod.scores.speed,
1868
- selectedMod.scores.activity,
1869
- selectedMod.scores.harshness,
1870
- selectedMod.scores.watchfulness
1871
- ],
1872
- backgroundColor: 'rgba(163, 112, 247, 0.2)',
1873
- borderColor: '#A370F7',
1874
- borderWidth: 2,
1875
- pointBackgroundColor: '#A370F7',
1876
- pointBorderColor: '#FFF',
1877
- pointHoverBackgroundColor: '#FFF',
1878
- pointHoverBorderColor: '#A370F7'
1879
- }]
1880
- }}
1881
- options={{
1882
- plugins: { legend: { display: false } },
1883
- scales: {
1884
- r: {
1885
- angleLines: { color: '#2F2F35' },
1886
- grid: { color: '#2F2F35' },
1887
- pointLabels: { color: '#ADADB8', font: { size: 10, weight: 'bold' } },
1888
- ticks: { display: false },
1889
- min: 0,
1890
- max: 100
1891
- }
1892
- }
1893
- }}
1894
- />
1895
- </div>
1896
-
1897
- {/* Actions distribution */}
1898
- <div style={{ marginBottom: '1rem' }}>
1899
- <h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>Распределение наказаний:</h4>
1900
- <div style={{ height: '16px', display: 'flex', borderRadius: '4px', overflow: 'hidden', background: 'var(--color-bg-base)' }}>
1901
- {selectedMod.bans_count > 0 && (
1902
- <div
1903
- style={{ width: `${(selectedMod.bans_count / selectedMod.total_actions) * 100}%`, background: '#FF0055', height: '100%' }}
1904
- title={`Баны: ${selectedMod.bans_count}`}
1905
- />
1906
- )}
1907
- {selectedMod.timeouts_count > 0 && (
1908
- <div
1909
- style={{ width: `${(selectedMod.timeouts_count / selectedMod.total_actions) * 100}%`, background: '#FFAA00', height: '100%' }}
1910
- title={`Муты/Таймауты: ${selectedMod.timeouts_count}`}
1911
- />
1912
- )}
1913
- {selectedMod.deletions_count > 0 && (
1914
- <div
1915
- style={{ width: `${(selectedMod.deletions_count / selectedMod.total_actions) * 100}%`, background: '#00AAFF', height: '100%' }}
1916
- title={`Удаления сообщений: ${selectedMod.deletions_count}`}
1917
- />
1918
- )}
1919
- {selectedMod.unbans_count > 0 && (
1920
- <div
1921
- style={{ width: `${(selectedMod.unbans_count / selectedMod.total_actions) * 100}%`, background: '#00FF88', height: '100%' }}
1922
- title={`Разбаны: ${selectedMod.unbans_count}`}
1923
- />
1924
- )}
1925
- </div>
1926
- {/* Legend */}
1927
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem 1.25rem', marginTop: '0.5rem', fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
1928
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1929
- <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FF0055' }}></span>
1930
- Баны ({selectedMod.bans_count})
1931
- </div>
1932
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1933
- <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FFAA00' }}></span>
1934
- Муты ({selectedMod.timeouts_count})
1935
- </div>
1936
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1937
- <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00AAFF' }}></span>
1938
- Удаления ({selectedMod.deletions_count})
1939
- </div>
1940
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1941
- <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00FF88' }}></span>
1942
- Разбаны ({selectedMod.unbans_count})
1943
- </div>
1944
- </div>
1945
- </div>
1946
- </div>
1947
- )}
1948
- </div>
1949
- </div>
1950
- </div>
1951
- )}
1952
- </div>
1953
  )}
1954
  </>
1955
  )}
 
29
  Legend,
30
  Filler
31
  } from 'chart.js';
32
+
33
+ import OverviewTab from './components/OverviewTab';
34
+ import ChattersTab from './components/ChattersTab';
35
+ import WordsTab from './components/WordsTab';
36
+ import AdminTab from './components/AdminTab';
37
+ import ModeratorTab from './components/ModeratorTab';
38
 
39
  // Register Chart.js components
40
  ChartJS.register(
 
53
  const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
54
  const TWITCH_CHANNEL = 'winx_prinx';
55
 
56
+ // ChatterOrbit component has been extracted to components/ChatterOrbit.jsx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  export default function App() {
59
  const [activeTab, setActiveTab] = useState('overview');
 
666
  </div>
667
  ) : (
668
  <>
 
669
  {activeTab === 'overview' && (
670
+ <OverviewTab
671
+ statsSummary={statsSummary}
672
+ selectedStreamId={selectedStreamId}
673
+ activityData={activityData}
674
+ isRacing={isRacing}
675
+ startGraphRace={startGraphRace}
676
+ chartDataConfig={chartDataConfig}
677
+ chartOptions={chartOptions}
678
+ voiceWords={voiceWords}
679
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  )}
681
 
 
682
  {activeTab === 'chatters' && (
683
+ <ChattersTab
684
+ chatters={chatters}
685
+ chattersSearch={chattersSearch}
686
+ setChattersSearch={setChattersSearch}
687
+ TWITCH_CHANNEL={TWITCH_CHANNEL}
688
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  )}
690
 
 
691
  {activeTab === 'words' && (
692
+ <WordsTab
693
+ wordType={wordType}
694
+ setWordType={setWordType}
695
+ voiceWords={voiceWords}
696
+ chatWords={chatWords}
697
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  )}
699
 
 
700
  {activeTab === 'admin' && (
701
+ <AdminTab
702
+ adminSelectedStreamId={adminSelectedStreamId}
703
+ setAdminSelectedStreamId={setAdminSelectedStreamId}
704
+ streams={streams}
705
+ editTitle={editTitle}
706
+ setEditTitle={setEditTitle}
707
+ editCategory={editCategory}
708
+ setEditCategory={setEditCategory}
709
+ savingMetadata={savingMetadata}
710
+ setSavingMetadata={setSavingMetadata}
711
+ deletingStream={deletingStream}
712
+ setDeletingStream={setDeletingStream}
713
+ syncingVods={syncingVods}
714
+ setSyncingVods={setSyncingVods}
715
+ cleaningStreams={cleaningStreams}
716
+ setCleaningStreams={setCleaningStreams}
717
+ loadingAdminStats={loadingAdminStats}
718
+ adminStats={adminStats}
719
+ fetchAdminStats={fetchAdminStats}
720
+ fetchStreams={fetchStreams}
721
+ API_BASE={API_BASE}
722
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  )}
724
 
 
725
  {activeTab === 'moderator' && (
726
+ <ModeratorTab
727
+ twitchConfigured={twitchConfigured}
728
+ auth={auth}
729
+ handleLogin={handleLogin}
730
+ TWITCH_CHANNEL={TWITCH_CHANNEL}
731
+ modProfiles={modProfiles}
732
+ selectedMod={selectedMod}
733
+ setSelectedMod={setSelectedMod}
734
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  )}
736
  </>
737
  )}
client/src/components/AdminTab.jsx ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Settings, Trash2, RefreshCw } from 'lucide-react';
3
+
4
+ export default function AdminTab({
5
+ adminSelectedStreamId,
6
+ setAdminSelectedStreamId,
7
+ streams,
8
+ editTitle,
9
+ setEditTitle,
10
+ editCategory,
11
+ setEditCategory,
12
+ savingMetadata,
13
+ setSavingMetadata,
14
+ deletingStream,
15
+ setDeletingStream,
16
+ syncingVods,
17
+ setSyncingVods,
18
+ cleaningStreams,
19
+ setCleaningStreams,
20
+ loadingAdminStats,
21
+ adminStats,
22
+ fetchAdminStats,
23
+ fetchStreams,
24
+ API_BASE
25
+ }) {
26
+ return (
27
+ <div className="tab-content">
28
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
29
+ <Settings size={24} style={{ color: 'var(--color-brand)' }} /> Панель администратора
30
+ </h2>
31
+
32
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
33
+
34
+ {/* Left: Stream management */}
35
+ <div className="panel" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
36
+ <div className="panel-header">
37
+ <h3 className="panel-title">Управление стримами</h3>
38
+ </div>
39
+
40
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
41
+ <label style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Выбрать стрим для редактирования или удаления:</label>
42
+ <select
43
+ className="dark-input"
44
+ value={adminSelectedStreamId}
45
+ onChange={(e) => setAdminSelectedStreamId(e.target.value)}
46
+ >
47
+ <option value="">-- Выберите стрим --</option>
48
+ {streams.map((stream) => (
49
+ <option key={stream.id} value={stream.id}>
50
+ {stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })})
51
+ </option>
52
+ ))}
53
+ </select>
54
+ </div>
55
+
56
+ {(() => {
57
+ const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
58
+ if (!activeStream) return (
59
+ <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
60
+ Выберите стрим в выпадающем списке выше для выполнения действий.
61
+ </div>
62
+ );
63
+
64
+ const statusLabel =
65
+ activeStream.backfill_status === 'completed' ? 'Импортирован полностью' :
66
+ activeStream.backfill_status === 'pending' ? 'В очереди воркера (ожидание)' :
67
+ activeStream.backfill_status === 'live' ? 'В эфире (live)' : activeStream.backfill_status;
68
+
69
+ const statusColor =
70
+ activeStream.backfill_status === 'completed' ? 'var(--color-success, #00f2fe)' :
71
+ activeStream.backfill_status === 'pending' ? 'var(--color-warning, #f1c40f)' : 'var(--color-primary)';
72
+
73
+ return (
74
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
75
+ <div style={{ fontSize: '0.8rem', padding: '0.75rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px solid var(--color-border)' }}>
76
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>ID сессии:</strong> {activeStream.id}</p>
77
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>Статус импорта VOD:</strong> <span style={{ color: statusColor, fontWeight: 'bold' }}>{statusLabel}</span></p>
78
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>Twitch VOD ID:</strong> {activeStream.twitch_vod_id || 'Отсутствует'}</p>
79
+ <p style={{ margin: 0 }}><strong>Начало:</strong> {new Date(activeStream.start_time).toLocaleString('ru-RU')}</p>
80
+ </div>
81
+
82
+ {/* Edit Metadata */}
83
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: 'rgba(255,255,255,0.01)', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.03)' }}>
84
+ <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Редактирование названия / категории</h4>
85
+
86
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
87
+ <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Название стрима:</label>
88
+ <input
89
+ type="text"
90
+ className="dark-input"
91
+ value={editTitle}
92
+ onChange={(e) => setEditTitle(e.target.value)}
93
+ />
94
+ </div>
95
+
96
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
97
+ <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Категория (игра):</label>
98
+ <input
99
+ type="text"
100
+ className="dark-input"
101
+ value={editCategory}
102
+ onChange={(e) => setEditCategory(e.target.value)}
103
+ />
104
+ </div>
105
+
106
+ <button
107
+ className="btn btn-secondary"
108
+ style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
109
+ disabled={savingMetadata || !editTitle.trim()}
110
+ onClick={async () => {
111
+ setSavingMetadata(true);
112
+ try {
113
+ const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
114
+ method: 'PUT',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ title: editTitle, category: editCategory }),
117
+ credentials: 'include'
118
+ });
119
+ const data = await res.json();
120
+ if (data.success) {
121
+ alert('Метаданные стрима успешно обновлены!');
122
+ fetchStreams();
123
+ } else {
124
+ alert(`Ошибка: ${data.error}`);
125
+ }
126
+ } catch (e) {
127
+ alert('Ошибка при обновлении метаданных.');
128
+ } finally {
129
+ setSavingMetadata(false);
130
+ }
131
+ }}
132
+ >
133
+ {savingMetadata ? 'Сохранение...' : 'Сохранить изменения'}
134
+ </button>
135
+ </div>
136
+
137
+ {/* VOD Actions (Only if VOD is present) */}
138
+ {activeStream.twitch_vod_id && (
139
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
140
+ <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Действия импорта VOD:</h4>
141
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
142
+ <button
143
+ className="btn btn-secondary"
144
+ style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
145
+ onClick={async () => {
146
+ if (!confirm(`Запустить заполнение пропусков для стрима "${activeStream.title}"? Воркер докачает пропущенный чат и Whisper-речь.`)) return;
147
+ try {
148
+ const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({ streamId: activeStream.id, mode: 'gap_fill' }),
152
+ credentials: 'include'
153
+ });
154
+ const data = await res.json();
155
+ if (data.success) {
156
+ alert('Статус сброшен на "ожидание". Воркер скоро начнет дозаполнение!');
157
+ fetchStreams();
158
+ } else {
159
+ alert(`Ошибка: ${data.error}`);
160
+ }
161
+ } catch (e) {
162
+ alert('Ошибка при запуске дозаполнения VOD.');
163
+ }
164
+ }}
165
+ >
166
+ Заполнить пропуски VOD
167
+ </button>
168
+ <button
169
+ className="btn btn-secondary"
170
+ style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
171
+ onClick={async () => {
172
+ if (!confirm(`Внимание: это полностью удалит сохраненные сообщения чата и слова Whisper для стрима "${activeStream.title}" и заново запустит весь импорт VOD с 0-й секунды. Вы уверены?`)) return;
173
+ try {
174
+ const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ streamId: activeStream.id, mode: 'full_rebuild' }),
178
+ credentials: 'include'
179
+ });
180
+ const data = await res.json();
181
+ if (data.success) {
182
+ alert('Данные очищены. Стрим поставлен на полный переимпорт воркером!');
183
+ fetchStreams();
184
+ } else {
185
+ alert(`Ошибка: ${data.error}`);
186
+ }
187
+ } catch (e) {
188
+ alert('Ошибка при запуске переимпорта.');
189
+ }
190
+ }}
191
+ >
192
+ Полный переимпорт VOD
193
+ </button>
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {/* Delete Button */}
199
+ <div style={{ borderTop: '1px solid var(--color-border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
200
+ <button
201
+ className="btn btn-danger"
202
+ style={{
203
+ width: '100%',
204
+ display: 'flex',
205
+ alignItems: 'center',
206
+ justifyContent: 'center',
207
+ gap: '0.4rem',
208
+ backgroundColor: 'rgba(231, 76, 60, 0.15)',
209
+ color: '#e74c3c',
210
+ border: '1px solid rgba(231, 76, 60, 0.3)',
211
+ borderRadius: '4px',
212
+ cursor: 'pointer',
213
+ padding: '0.6rem'
214
+ }}
215
+ disabled={deletingStream}
216
+ onClick={async () => {
217
+ if (!confirm(`ВНИМАНИЕ: Вы уверены, что хотите полностью УДАЛИТЬ стрим "${activeStream.title}"? Это действие сотрет всю связанную статистику (чат, слова Whisper, модерацию) из базы данных навсегда и безвозвратно!`)) return;
218
+ if (!confirm(`ПОСЛЕДНЕЕ ПРЕДУПРЕЖДЕНИЕ: Вы действительно хотите стереть стрим ID ${activeStream.id} из базы? Восстановление невозможно.`)) return;
219
+
220
+ setDeletingStream(true);
221
+ try {
222
+ const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
223
+ method: 'DELETE',
224
+ credentials: 'include'
225
+ });
226
+ const data = await res.json();
227
+ if (data.success) {
228
+ alert('Стрим успешно удален из базы данных!');
229
+ setAdminSelectedStreamId('');
230
+ fetchStreams();
231
+ } else {
232
+ alert(`Ошибка: ${data.error}`);
233
+ }
234
+ } catch (e) {
235
+ alert('Ошибка при удалении стрима.');
236
+ } finally {
237
+ setDeletingStream(false);
238
+ }
239
+ }}
240
+ >
241
+ <Trash2 size={16} /> Удалить стрим полностью
242
+ </button>
243
+ </div>
244
+ </div>
245
+ );
246
+ })()}
247
+ </div>
248
+
249
+ {/* Right: Global actions and Stats */}
250
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
251
+
252
+ {/* Global Actions Panel */}
253
+ <div className="panel">
254
+ <div className="panel-header">
255
+ <h3 className="panel-title">Глобальные действия</h3>
256
+ </div>
257
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
258
+
259
+ {/* Twitch VOD Sync */}
260
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '0.75rem', borderBottom: '1px solid var(--color-border)' }}>
261
+ <div>
262
+ <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Синхронизация Twitch VOD</h4>
263
+ <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Запросить последние 20 архивов из Twitch API.</p>
264
+ </div>
265
+ <button
266
+ className="btn btn-secondary"
267
+ style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
268
+ disabled={syncingVods}
269
+ onClick={async () => {
270
+ setSyncingVods(true);
271
+ try {
272
+ const res = await fetch(`${API_BASE}/api/streams/sync-vods`, { method: 'POST', credentials: 'include' });
273
+ const data = await res.json();
274
+ if (data.success) {
275
+ alert(`Успешно импортировано ${data.count} стримов!`);
276
+ fetchStreams();
277
+ } else {
278
+ alert(`Ошибка: ${data.error}`);
279
+ }
280
+ } catch (e) {
281
+ alert('Ошибка при синхронизации VOD.');
282
+ } finally {
283
+ setSyncingVods(false);
284
+ }
285
+ }}
286
+ >
287
+ <RefreshCw size={12} className={syncingVods ? "spin" : ""} /> {syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'}
288
+ </button>
289
+ </div>
290
+
291
+ {/* Database Cleanup */}
292
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
293
+ <div>
294
+ <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Очистить пустые стримы</h4>
295
+ <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Удалить сессии с 0 сообщениями и 0 слов.</p>
296
+ </div>
297
+ <button
298
+ className="btn btn-secondary"
299
+ style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
300
+ disabled={cleaningStreams}
301
+ onClick={async () => {
302
+ if (!confirm('Вы действительно хотите удалить все пустые стримы (в которых нет ни сообщений в чате, ни распознанных слов)? Это очистит тестовый мусор из списка.')) return;
303
+ setCleaningStreams(true);
304
+ try {
305
+ const res = await fetch(`${API_BASE}/api/admin/cleanup`, { method: 'POST', credentials: 'include' });
306
+ const data = await res.json();
307
+ if (data.success) {
308
+ alert(`Успешно очищено. Удалено пустых стримов: ${data.count}`);
309
+ fetchStreams();
310
+ fetchAdminStats();
311
+ } else {
312
+ alert(`Ошибка: ${data.error}`);
313
+ }
314
+ } catch (e) {
315
+ alert('Ошибка при очистке стримов.');
316
+ } finally {
317
+ setCleaningStreams(false);
318
+ }
319
+ }}
320
+ >
321
+ Очистить пустые стримы
322
+ </button>
323
+ </div>
324
+
325
+ </div>
326
+ </div>
327
+
328
+ {/* System Statistics Panel */}
329
+ <div className="panel">
330
+ <div className="panel-header">
331
+ <h3 className="panel-title">Системная статистика</h3>
332
+ </div>
333
+
334
+ {loadingAdminStats || !adminStats ? (
335
+ <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
336
+ Загрузка статистики...
337
+ </div>
338
+ ) : (
339
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', fontSize: '0.8rem' }}>
340
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
341
+ <span style={{ color: 'var(--color-text-muted)' }}>Режим базы данных:</span>
342
+ <strong style={{ color: 'var(--color-brand)' }}>{adminStats.dbMode.toUpperCase()}</strong>
343
+ </div>
344
+ {adminStats.dbMode === 'sqlite' && (
345
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
346
+ <span style={{ color: 'var(--color-text-muted)' }}>Размер файла SQLite:</span>
347
+ <strong>{adminStats.dbSizeMb} МБ</strong>
348
+ </div>
349
+ )}
350
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
351
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего стримов в базе:</span>
352
+ <strong>{adminStats.totalStreams}</strong>
353
+ </div>
354
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
355
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего сообщений чата:</span>
356
+ <strong>{adminStats.totalMessages.toLocaleString()}</strong>
357
+ </div>
358
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
359
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего голосовых слов:</span>
360
+ <strong>{adminStats.totalVoiceWords.toLocaleString()}</strong>
361
+ </div>
362
+
363
+ <button
364
+ className="btn btn-secondary"
365
+ style={{ width: '100%', fontSize: '0.75rem', padding: '0.4rem', marginTop: '0.5rem' }}
366
+ onClick={fetchAdminStats}
367
+ >
368
+ Обновить статистику
369
+ </button>
370
+ </div>
371
+ )}
372
+ </div>
373
+
374
+ </div>
375
+
376
+ </div>
377
+ </div>
378
+ );
379
+ }
client/src/components/ChatterOrbit.jsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
4
+ const TWITCH_CHANNEL = 'winx_prinx';
5
+
6
+ export default function ChatterOrbit({ chatters }) {
7
+ const canvasRef = useRef(null);
8
+ const containerRef = useRef(null);
9
+ const particlesRef = useRef(new Map());
10
+ const avatarRef = useRef(null);
11
+
12
+ useEffect(() => {
13
+ // Load Avatar via backend proxy (avoids CORS issues with decapi.me)
14
+ if (!avatarRef.current) {
15
+ const img = new Image();
16
+ img.src = `${API_BASE}/api/avatar/${TWITCH_CHANNEL}`;
17
+ img.onload = () => { avatarRef.current = img; };
18
+ img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed');
19
+ }
20
+ }, []);
21
+
22
+ useEffect(() => {
23
+ const safeChatters = Array.isArray(chatters) ? chatters : [];
24
+
25
+ safeChatters.forEach(c => {
26
+ const username = c.username || c.display_name || 'unknown';
27
+ if (!particlesRef.current.has(username)) {
28
+ const isLurker = !c.has_chatted && !(c.message_count > 0);
29
+ // All particles go on the ring — tight band around ring radius ±8%
30
+ const ringNoise = (Math.random() - 0.5) * 0.16;
31
+ const radiusNorm = 1.0 + ringNoise;
32
+
33
+ particlesRef.current.set(username, {
34
+ username,
35
+ displayName: c.display_name || username,
36
+ angle: Math.random() * Math.PI * 2,
37
+ radiusNorm,
38
+ speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1),
39
+ isMod: c.is_mod,
40
+ isSub: c.is_sub,
41
+ isLurker,
42
+ messageCount: c.message_count || 0,
43
+ isStreamer: username.toLowerCase() === 'winx_prinx',
44
+ });
45
+ } else {
46
+ const p = particlesRef.current.get(username);
47
+ const isLurker = !c.has_chatted && !(c.message_count > 0);
48
+ p.isLurker = isLurker;
49
+ p.messageCount = c.message_count || p.messageCount;
50
+ p.isMod = c.is_mod || p.isMod;
51
+ p.isSub = c.is_sub || p.isSub;
52
+ }
53
+ });
54
+ }, [chatters]);
55
+
56
+ useEffect(() => {
57
+ const canvas = canvasRef.current;
58
+ const container = containerRef.current;
59
+ if (!canvas || !container) return;
60
+ const ctx = canvas.getContext('2d');
61
+ let animationId;
62
+
63
+ const HEIGHT = 320;
64
+
65
+ const resize = () => {
66
+ const dpr = window.devicePixelRatio || 1;
67
+ const rect = container.getBoundingClientRect();
68
+ canvas.width = rect.width * dpr;
69
+ canvas.height = HEIGHT * dpr;
70
+ canvas.style.width = `${rect.width}px`;
71
+ canvas.style.height = `${HEIGHT}px`;
72
+ ctx.scale(dpr, dpr);
73
+ };
74
+ resize();
75
+ window.addEventListener('resize', resize);
76
+
77
+ let hoverName = null;
78
+ let hoverX = 0;
79
+ let hoverY = 0;
80
+ let mouseX = -9999;
81
+ let mouseY = -9999;
82
+
83
+ const handleMouseMove = (e) => {
84
+ const rect = canvas.getBoundingClientRect();
85
+ mouseX = e.clientX - rect.left;
86
+ mouseY = e.clientY - rect.top;
87
+ };
88
+ const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; };
89
+ canvas.addEventListener('mousemove', handleMouseMove);
90
+ canvas.addEventListener('mouseleave', handleMouseLeave);
91
+
92
+ const animate = () => {
93
+ const width = canvas.width / (window.devicePixelRatio || 1);
94
+ const height = HEIGHT;
95
+ const cx = width / 2;
96
+ const cy = height / 2;
97
+
98
+ // Ring fills most of the canvas
99
+ const RX = width * 0.44;
100
+ const RY = height * 0.36;
101
+ const TILT = RY / RX;
102
+
103
+ // Solid near-black background
104
+ ctx.fillStyle = '#08080f';
105
+ ctx.fillRect(0, 0, width, height);
106
+
107
+ const particles = Array.from(particlesRef.current.values());
108
+
109
+ hoverName = null;
110
+
111
+ // Update positions
112
+ particles.forEach(p => {
113
+ p.angle += p.speed;
114
+ const r = p.radiusNorm * RX;
115
+ p.x = cx + Math.cos(p.angle) * r;
116
+ p.y = cy + Math.sin(p.angle) * r * TILT;
117
+ p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front
118
+
119
+ if (p.isStreamer) {
120
+ p.baseSize = 5;
121
+ p.color = '#A370F7';
122
+ } else if (p.isLurker) {
123
+ p.baseSize = 1;
124
+ p.color = null;
125
+ } else {
126
+ p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2);
127
+ p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8';
128
+ }
129
+ p.drawSize = p.baseSize;
130
+ });
131
+
132
+ // Sort back-to-front
133
+ particles.sort((a, b) => a.depth - b.depth);
134
+
135
+ // Draw particles
136
+ particles.forEach(p => {
137
+ const alpha = p.isLurker
138
+ ? 0.15 + p.depth * 0.45
139
+ : 0.5 + p.depth * 0.5;
140
+
141
+ let color;
142
+ if (p.isLurker) {
143
+ const brightness = Math.round(160 + p.depth * 60);
144
+ color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`;
145
+ } else {
146
+ color = p.color;
147
+ }
148
+
149
+ const dist = Math.hypot(mouseX - p.x, mouseY - p.y);
150
+ if (dist < p.drawSize + 6) {
151
+ hoverName = p.displayName;
152
+ hoverX = p.x;
153
+ hoverY = p.y;
154
+ p.drawSize = Math.max(p.drawSize * 2.5, 5);
155
+ }
156
+
157
+ ctx.globalAlpha = p.isLurker ? 1 : alpha;
158
+ ctx.shadowBlur = 0;
159
+
160
+ if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) {
161
+ ctx.shadowBlur = 6;
162
+ ctx.shadowColor = color;
163
+ }
164
+
165
+ ctx.beginPath();
166
+ ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2);
167
+ ctx.fillStyle = color;
168
+ ctx.fill();
169
+ });
170
+ ctx.globalAlpha = 1;
171
+ ctx.shadowBlur = 0;
172
+
173
+ // Draw Avatar on top
174
+ ctx.save();
175
+ ctx.beginPath();
176
+ ctx.arc(cx, cy, 26, 0, Math.PI * 2);
177
+ ctx.closePath();
178
+ ctx.shadowColor = '#9147ff';
179
+ ctx.shadowBlur = 24;
180
+ ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)';
181
+ ctx.lineWidth = 2.5;
182
+ ctx.stroke();
183
+ ctx.shadowBlur = 0;
184
+ ctx.clip();
185
+ if (avatarRef.current) {
186
+ ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52);
187
+ } else {
188
+ ctx.fillStyle = '#6441A5';
189
+ ctx.fill();
190
+ }
191
+ ctx.restore();
192
+
193
+ // Hover tooltip
194
+ if (hoverName) {
195
+ ctx.shadowBlur = 0;
196
+ ctx.globalAlpha = 1;
197
+ ctx.font = 'bold 11px Inter, sans-serif';
198
+ const tw = ctx.measureText(hoverName).width;
199
+ const tx = Math.min(hoverX + 12, width - tw - 20);
200
+ const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30;
201
+ ctx.fillStyle = 'rgba(18,18,24,0.92)';
202
+ ctx.beginPath();
203
+ if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5);
204
+ else ctx.rect(tx - 4, ty, tw + 16, 22);
205
+ ctx.fill();
206
+ ctx.strokeStyle = 'rgba(145,71,255,0.5)';
207
+ ctx.lineWidth = 1;
208
+ ctx.stroke();
209
+ ctx.fillStyle = '#efeff1';
210
+ ctx.fillText(hoverName, tx + 4, ty + 15);
211
+ }
212
+
213
+ animationId = requestAnimationFrame(animate);
214
+ };
215
+
216
+ animate();
217
+
218
+ return () => {
219
+ cancelAnimationFrame(animationId);
220
+ canvas.removeEventListener('mousemove', handleMouseMove);
221
+ canvas.removeEventListener('mouseleave', handleMouseLeave);
222
+ window.removeEventListener('resize', resize);
223
+ };
224
+ }, []);
225
+
226
+ return (
227
+ <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', width: '100%', position: 'relative' }}>
228
+ <canvas ref={canvasRef} style={{ borderRadius: '8px', display: 'block', width: '100%' }} />
229
+ <div style={{ position: 'absolute', bottom: '10px', right: '12px', fontSize: '11px', color: 'rgba(180,180,200,0.7)', display: 'flex', gap: '14px' }}>
230
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: 'rgba(200,200,220,0.5)', borderRadius: '50%', marginRight: '4px'}}></span>Зрители</span>
231
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#c8c8d8', borderRadius: '50%', marginRight: '4px'}}></span>Чатеры</span>
232
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#FF007F', borderRadius: '50%', marginRight: '4px'}}></span>Сабы</span>
233
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#00F5D4', borderRadius: '50%', marginRight: '4px'}}></span>Модеры</span>
234
+ </div>
235
+ </div>
236
+ );
237
+ }
client/src/components/ChattersTab.jsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Users, Search } from 'lucide-react';
3
+ import ChatterOrbit from './ChatterOrbit';
4
+
5
+ export default function ChattersTab({
6
+ chatters,
7
+ chattersSearch,
8
+ setChattersSearch,
9
+ TWITCH_CHANNEL
10
+ }) {
11
+ return (
12
+ <div className="grid-2col tab-content" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
13
+ <div className="panel">
14
+ <div className="panel-header">
15
+ <h3 className="panel-title"><Users size={20} /> Орбита зрителей</h3>
16
+ </div>
17
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
18
+ <ChatterOrbit chatters={chatters} />
19
+ </div>
20
+ </div>
21
+
22
+ <div className="panel">
23
+ <div className="panel-header">
24
+ <h3 className="panel-title"><Users size={20} /> Лидеры чата по активности</h3>
25
+ <div className="search-container">
26
+ <Search size={16} className="search-icon" />
27
+ <input
28
+ type="text"
29
+ className="search-input"
30
+ placeholder="Поиск зрителя..."
31
+ value={chattersSearch}
32
+ onChange={(e) => setChattersSearch(e.target.value)}
33
+ />
34
+ </div>
35
+ </div>
36
+
37
+ {chatters.length === 0 ? (
38
+ <div className="status-msg">
39
+ <Users size={40} className="status-msg-icon" />
40
+ <p>Нет данных о зрителях для этого периода</p>
41
+ </div>
42
+ ) : (
43
+ <div className="table-wrapper">
44
+ <table className="data-table">
45
+ <thead>
46
+ <tr>
47
+ <th>Место</th>
48
+ <th>Никнейм</th>
49
+ <th>Роли</th>
50
+ <th>Сообщений</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ {chatters
55
+ .filter(c => {
56
+ const disp = c.display_name || c.username || '';
57
+ const user = c.username || '';
58
+ return disp.toLowerCase().includes(chattersSearch.toLowerCase()) ||
59
+ user.toLowerCase().includes(chattersSearch.toLowerCase());
60
+ })
61
+ .map((c, idx) => (
62
+ <tr key={idx} className="chatter-row">
63
+ <td style={{ fontWeight: 700, width: '80px', color: idx < 3 ? 'var(--color-brand)' : 'var(--color-text-muted)' }}>
64
+ #{idx + 1}
65
+ </td>
66
+ <td style={{ fontWeight: 600 }}>{c.display_name || c.username || '—'}</td>
67
+ <td>
68
+ <div style={{ display: 'flex', gap: '0.4rem' }}>
69
+ {(c.username || '').toLowerCase() === TWITCH_CHANNEL && (
70
+ <span className="badge badge-streamer">Стример</span>
71
+ )}
72
+ {c.is_mod ? <span className="badge badge-mod">Мод</span> : null}
73
+ {c.is_vip && !c.is_mod ? <span className="badge badge-vip">VIP</span> : null}
74
+ {c.is_sub && !c.is_mod && !c.is_vip ? <span className="badge badge-sub">Саб</span> : null}
75
+ </div>
76
+ </td>
77
+ <td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}>
78
+ {c.message_count.toLocaleString()}
79
+ </td>
80
+ </tr>
81
+ ))}
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ )}
86
+ </div>
87
+ </div>
88
+ );
89
+ }
client/src/components/ModeratorTab.jsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Lock, LogIn, ShieldAlert, UserCheck } from 'lucide-react';
3
+ import { Radar } from 'react-chartjs-2';
4
+
5
+ export default function ModeratorTab({
6
+ twitchConfigured,
7
+ auth,
8
+ handleLogin,
9
+ TWITCH_CHANNEL,
10
+ modProfiles,
11
+ selectedMod,
12
+ setSelectedMod
13
+ }) {
14
+ return (
15
+ <div className="tab-content">
16
+ {twitchConfigured && !auth.loggedIn ? (
17
+ <div className="login-overlay">
18
+ <div className="login-card">
19
+ <Lock size={48} style={{ color: 'var(--color-brand)', marginBottom: '1.5rem' }} />
20
+ <h2 className="login-title">Доступ Ограничен</h2>
21
+ <p className="login-description">
22
+ Лог действий модераторов и статистика банов/удалений доступны только стримеру и официальным модераторам канала **winx_prinx**.
23
+ Пожалуйста, авторизуйтесь через Twitch для проверки прав.
24
+ </p>
25
+ <button className="btn" onClick={handleLogin}>
26
+ <LogIn size={16} /> Войти через Twitch
27
+ </button>
28
+ </div>
29
+ </div>
30
+ ) : twitchConfigured && (auth.user?.role !== 'streamer' && auth.user?.role !== 'moderator' && auth.user?.role !== 'admin') ? (
31
+ <div className="status-msg">
32
+ <ShieldAlert size={48} className="status-msg-icon" />
33
+ <h2>Недостаточно прав</h2>
34
+ <p>Вы успешно вошли как <strong>{auth.user?.displayName}</strong>, но вы не являетесь модератором канала {TWITCH_CHANNEL}.</p>
35
+ </div>
36
+ ) : (
37
+ <div>
38
+ {modProfiles.length <= 2 && (
39
+ <div style={{ background: 'rgba(145, 70, 255, 0.1)', border: '1px solid rgba(145, 70, 255, 0.3)', borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
40
+ <ShieldAlert size={24} style={{ color: 'var(--color-brand)' }} />
41
+ <div>
42
+ <h4 style={{ margin: '0 0 0.25rem 0', color: 'var(--color-text-main)', fontSize: '0.95rem' }}>Данные накапливаются</h4>
43
+ <p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Информация о модераторах появляется по мере их активности в выбранном стриме.</p>
44
+ </div>
45
+ </div>
46
+ )}
47
+ {/* TOP 3 PODIUM */}
48
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
49
+ {modProfiles.slice(0, 3).map((mod, idx) => {
50
+ const trophyColor = idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32';
51
+ const glowStyle = {
52
+ border: `1px solid ${trophyColor}40`,
53
+ boxShadow: `0 0 15px ${trophyColor}12`,
54
+ position: 'relative',
55
+ overflow: 'hidden'
56
+ };
57
+ return (
58
+ <div key={idx} className="panel" style={glowStyle}>
59
+ <div style={{ position: 'absolute', top: '-10px', right: '-10px', fontSize: '5rem', opacity: 0.08, color: trophyColor, fontWeight: 900 }}>
60
+ #{idx + 1}
61
+ </div>
62
+ <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
63
+ <div style={{
64
+ width: '44px',
65
+ height: '44px',
66
+ borderRadius: '50%',
67
+ background: 'var(--color-bg-base)',
68
+ border: `2px solid ${trophyColor}`,
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ fontWeight: 700,
73
+ fontSize: '1.1rem',
74
+ color: trophyColor
75
+ }}>
76
+ {idx === 0 ? '👑' : idx === 1 ? '🥈' : '🥉'}
77
+ </div>
78
+ <div>
79
+ <h4 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>{mod.moderator}</h4>
80
+ <span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
81
+ {idx === 0 ? 'Глава патруля' : idx === 1 ? 'Старший мод' : 'Защитник чата'}
82
+ </span>
83
+ </div>
84
+ </div>
85
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1.25rem', fontSize: '0.8rem' }}>
86
+ <div>
87
+ <div style={{ color: 'var(--color-text-muted)' }}>Действий</div>
88
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--color-text-accent)' }}>{mod.total_actions}</div>
89
+ </div>
90
+ <div>
91
+ <div style={{ color: 'var(--color-text-muted)' }}>Ср. Реакция</div>
92
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#00F5D4' }}>
93
+ {mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}
94
+ </div>
95
+ </div>
96
+ <div>
97
+ <div style={{ color: 'var(--color-text-muted)' }}>КПД Активности</div>
98
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#A370F7' }}>{mod.scores.activity}%</div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ );
103
+ })}
104
+ </div>
105
+
106
+ <div className="grid-2col" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
107
+ {/* Left: Mod List */}
108
+ <div className="panel">
109
+ <div className="panel-header">
110
+ <h3 className="panel-title"><ShieldAlert size={20} /> Рейтинг модераторов</h3>
111
+ </div>
112
+ {modProfiles.length === 0 ? (
113
+ <div className="status-msg">
114
+ <ShieldAlert size={40} className="status-msg-icon" />
115
+ <p>Модераторы еще не совершали действий в этом периоде</p>
116
+ </div>
117
+ ) : (
118
+ <div className="table-wrapper">
119
+ <table className="data-table">
120
+ <thead>
121
+ <tr>
122
+ <th>Ранг</th>
123
+ <th>Никнейм</th>
124
+ <th>Действий</th>
125
+ <th>Ср. Реакция</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody>
129
+ {modProfiles.map((mod, idx) => (
130
+ <tr
131
+ key={idx}
132
+ onClick={() => setSelectedMod(mod)}
133
+ style={{
134
+ cursor: 'pointer',
135
+ background: selectedMod && selectedMod.moderator === mod.moderator ? 'rgba(163, 112, 247, 0.08)' : 'transparent',
136
+ borderLeft: selectedMod && selectedMod.moderator === mod.moderator ? '3px solid var(--color-brand)' : 'none'
137
+ }}
138
+ >
139
+ <td>#{idx + 1}</td>
140
+ <td style={{ fontWeight: 600 }}>{mod.moderator}</td>
141
+ <td>{mod.total_actions}</td>
142
+ <td style={{ color: '#00F5D4', fontWeight: 600 }}>{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}</td>
143
+ </tr>
144
+ ))}
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ )}
149
+ </div>
150
+
151
+ {/* Right: Mod Dossier */}
152
+ <div className="panel">
153
+ <div className="panel-header">
154
+ <h3 className="panel-title"><UserCheck size={20} /> Личное досье модератора</h3>
155
+ </div>
156
+
157
+ {!selectedMod ? (
158
+ <div className="status-msg">
159
+ <UserCheck size={40} className="status-msg-icon" />
160
+ <p>Выберите модератора слева для просмотра досье</p>
161
+ </div>
162
+ ) : (
163
+ <div>
164
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
165
+ <div>
166
+ <h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-brand)' }}>{selectedMod.moderator}</h3>
167
+ <p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Анализ стиля модерирования и характеристик</p>
168
+ </div>
169
+ </div>
170
+
171
+ {/* Radar chart */}
172
+ <div style={{ height: '280px', display: 'flex', justifyContent: 'center', marginBottom: '1.25rem' }}>
173
+ <Radar
174
+ data={{
175
+ labels: ['Скорость', 'Активность', 'Жесткость', 'Внимательность'],
176
+ datasets: [{
177
+ data: [
178
+ selectedMod.scores.speed,
179
+ selectedMod.scores.activity,
180
+ selectedMod.scores.harshness,
181
+ selectedMod.scores.watchfulness
182
+ ],
183
+ backgroundColor: 'rgba(163, 112, 247, 0.2)',
184
+ borderColor: '#A370F7',
185
+ borderWidth: 2,
186
+ pointBackgroundColor: '#A370F7',
187
+ pointBorderColor: '#FFF',
188
+ pointHoverBackgroundColor: '#FFF',
189
+ pointHoverBorderColor: '#A370F7'
190
+ }]
191
+ }}
192
+ options={{
193
+ plugins: { legend: { display: false } },
194
+ scales: {
195
+ r: {
196
+ angleLines: { color: '#2F2F35' },
197
+ grid: { color: '#2F2F35' },
198
+ pointLabels: { color: '#ADADB8', font: { size: 10, weight: 'bold' } },
199
+ ticks: { display: false },
200
+ min: 0,
201
+ max: 100
202
+ }
203
+ }
204
+ }}
205
+ />
206
+ </div>
207
+
208
+ {/* Actions distribution */}
209
+ <div style={{ marginBottom: '1rem' }}>
210
+ <h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>Распределение наказаний:</h4>
211
+ <div style={{ height: '16px', display: 'flex', borderRadius: '4px', overflow: 'hidden', background: 'var(--color-bg-base)' }}>
212
+ {selectedMod.bans_count > 0 && (
213
+ <div
214
+ style={{ width: `${(selectedMod.bans_count / selectedMod.total_actions) * 100}%`, background: '#FF0055', height: '100%' }}
215
+ title={`Баны: ${selectedMod.bans_count}`}
216
+ />
217
+ )}
218
+ {selectedMod.timeouts_count > 0 && (
219
+ <div
220
+ style={{ width: `${(selectedMod.timeouts_count / selectedMod.total_actions) * 100}%`, background: '#FFAA00', height: '100%' }}
221
+ title={`Муты/Таймауты: ${selectedMod.timeouts_count}`}
222
+ />
223
+ )}
224
+ {selectedMod.deletions_count > 0 && (
225
+ <div
226
+ style={{ width: `${(selectedMod.deletions_count / selectedMod.total_actions) * 100}%`, background: '#00AAFF', height: '100%' }}
227
+ title={`Удаления сообщений: ${selectedMod.deletions_count}`}
228
+ />
229
+ )}
230
+ {selectedMod.unbans_count > 0 && (
231
+ <div
232
+ style={{ width: `${(selectedMod.unbans_count / selectedMod.total_actions) * 100}%`, background: '#00FF88', height: '100%' }}
233
+ title={`Разбаны: ${selectedMod.unbans_count}`}
234
+ />
235
+ )}
236
+ </div>
237
+ {/* Legend */}
238
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem 1.25rem', marginTop: '0.5rem', fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
239
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
240
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FF0055' }}></span>
241
+ Баны ({selectedMod.bans_count})
242
+ </div>
243
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
244
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FFAA00' }}></span>
245
+ Муты ({selectedMod.timeouts_count})
246
+ </div>
247
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
248
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00AAFF' }}></span>
249
+ Удаления ({selectedMod.deletions_count})
250
+ </div>
251
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
252
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00FF88' }}></span>
253
+ Разбаны ({selectedMod.unbans_count})
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+ </div>
262
+ )}
263
+ </div>
264
+ );
265
+ }
client/src/components/OverviewTab.jsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { MessageSquare, Users, Mic, Activity, BarChart2 } from 'lucide-react';
3
+ import { Line } from 'react-chartjs-2';
4
+
5
+ export default function OverviewTab({
6
+ statsSummary,
7
+ selectedStreamId,
8
+ activityData,
9
+ isRacing,
10
+ startGraphRace,
11
+ chartDataConfig,
12
+ chartOptions,
13
+ voiceWords
14
+ }) {
15
+ return (
16
+ <div className="tab-content">
17
+ {/* Stats row */}
18
+ <div className="grid-4col" style={{ marginBottom: '2rem' }}>
19
+ <div className="stat-card" style={{ '--accent-color': 'var(--color-brand)' }}>
20
+ <div className="stat-card-inner">
21
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
22
+ <div className="stat-label">Сообщений в чате</div>
23
+ <div className="stat-value">{statsSummary.messages.toLocaleString()}</div>
24
+ </div>
25
+ <div className="stat-icon-wrapper">
26
+ <MessageSquare size={22} style={{ color: 'var(--color-brand)' }} />
27
+ </div>
28
+ </div>
29
+ </div>
30
+ <div className="stat-card" style={{ '--accent-color': 'var(--color-text-accent)' }}>
31
+ <div className="stat-card-inner">
32
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
33
+ <div className="stat-label">Уникальных зрителей</div>
34
+ <div className="stat-value">{statsSummary.uniqueChatters.toLocaleString()}</div>
35
+ </div>
36
+ <div className="stat-icon-wrapper">
37
+ <Users size={22} style={{ color: 'var(--color-text-accent)' }} />
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <div className="stat-card" style={{ '--accent-color': '#ff007f' }}>
42
+ <div className="stat-card-inner">
43
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
44
+ <div className="stat-label">Самый активный чатер</div>
45
+ <div
46
+ className="stat-value"
47
+ title={statsSummary.mostActiveChatter || ''}
48
+ style={{
49
+ fontSize: (statsSummary.mostActiveChatter || '').length > 15 ? '1.05rem' : '1.25rem',
50
+ letterSpacing: '-0.02em',
51
+ overflow: 'hidden',
52
+ textOverflow: 'ellipsis',
53
+ whiteSpace: 'nowrap'
54
+ }}
55
+ >
56
+ {statsSummary.mostActiveChatter || '—'}
57
+ </div>
58
+ </div>
59
+ <div className="stat-icon-wrapper">
60
+ <Users size={22} style={{ color: '#ff007f' }} />
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <div className="stat-card" style={{ '--accent-color': '#00f5d4' }}>
65
+ <div className="stat-card-inner">
66
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
67
+ <div className="stat-label">Распознано слов (голос)</div>
68
+ <div className="stat-value">{statsSummary.voiceWordsCount.toLocaleString()}</div>
69
+ </div>
70
+ <div className="stat-icon-wrapper">
71
+ <Mic size={22} style={{ color: '#00f5d4' }} />
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div className="grid-2col" style={{ gridTemplateColumns: '2fr 1fr' }}>
78
+ {/* Activity graph */}
79
+ <div className="panel">
80
+ <div className="panel-header">
81
+ <h3 className="panel-title"><Activity size={20} /> Активность чата</h3>
82
+ <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
83
+ <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>выберите конкретный стрим для графика</span>
84
+ {activityData.length > 0 && (
85
+ <button
86
+ className="btn btn-secondary"
87
+ style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
88
+ onClick={startGraphRace}
89
+ disabled={isRacing}
90
+ >
91
+ <Activity size={12} className={isRacing ? "pulse" : ""} /> {isRacing ? 'Гонка идет...' : 'Запустить гонку'}
92
+ </button>
93
+ )}
94
+ </div>
95
+ </div>
96
+ {selectedStreamId === 'all' ? (
97
+ <div className="status-msg" style={{ height: '300px' }}>
98
+ <BarChart2 size={36} className="status-msg-icon" />
99
+ <p>Для просмотра временного графика выберите конкретный стрим в верхнем меню</p>
100
+ </div>
101
+ ) : activityData.length === 0 ? (
102
+ <div className="status-msg" style={{ height: '300px' }}>
103
+ <MessageSquare size={36} className="status-msg-icon" />
104
+ <p>В этом стриме пока не было сообщений</p>
105
+ </div>
106
+ ) : (
107
+ <div className="chart-container">
108
+ <Line data={chartDataConfig} options={chartOptions} />
109
+ </div>
110
+ )}
111
+ </div>
112
+
113
+ {/* Quick Words list */}
114
+ <div className="panel">
115
+ <div className="panel-header">
116
+ <h3 className="panel-title">
117
+ <Mic size={18} style={{ color: 'var(--color-text-accent)' }} /> Голос стримера (Топ)
118
+ </h3>
119
+ </div>
120
+ {voiceWords.length === 0 ? (
121
+ <div className="status-msg" style={{ height: '300px', padding: '1rem' }}>
122
+ <Mic size={28} className="status-msg-icon" />
123
+ <p>Слова пока не распознаны. Запустите local_worker на ПК.</p>
124
+ </div>
125
+ ) : (
126
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxHeight: '320px', overflowY: 'auto', paddingRight: '4px' }}>
127
+ {voiceWords.slice(0, 7).map((w, idx) => (
128
+ <div key={idx} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem', backgroundColor: 'var(--color-bg-base)', border: '1px solid var(--color-border)', borderRadius: '6px', alignItems: 'center' }}>
129
+ <span style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>#{idx+1} {w.word}</span>
130
+ <span className="badge badge-streamer">{w.word_count} раз</span>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ </div>
137
+ </div>
138
+ );
139
+ }
client/src/components/WordsTab.jsx ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Mic, Volume2, MessageSquare } from 'lucide-react';
3
+
4
+ export default function WordsTab({
5
+ wordType,
6
+ setWordType,
7
+ voiceWords,
8
+ chatWords
9
+ }) {
10
+ const currentWordsList = wordType === 'voice'
11
+ ? (Array.isArray(voiceWords) ? voiceWords : [])
12
+ : (Array.isArray(chatWords) ? chatWords : []);
13
+
14
+ if (currentWordsList.length === 0) {
15
+ return (
16
+ <div className="panel tab-content">
17
+ <div className="panel-header">
18
+ <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3>
19
+ <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}>
20
+ <button
21
+ className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`}
22
+ onClick={() => setWordType('voice')}
23
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
24
+ >
25
+ <Volume2 size={14} /> Из Голоса (Whisper)
26
+ </button>
27
+ <button
28
+ className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`}
29
+ onClick={() => setWordType('chat')}
30
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
31
+ >
32
+ <MessageSquare size={14} /> Из Чата (Текстом)
33
+ </button>
34
+ </div>
35
+ </div>
36
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}>
37
+ {wordType === 'voice'
38
+ ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).'
39
+ : 'Слова, которые зрители написали в текстовый чат Twitch.'}
40
+ </p>
41
+ <div className="status-msg">
42
+ <Mic size={40} className="status-msg-icon" />
43
+ <p>Нет собранных слов для выбранного стрима</p>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ const counts = currentWordsList.map(x => x.word_count);
50
+ const maxCount = Math.max(...counts, 1);
51
+ const minCount = Math.min(...counts, 1);
52
+
53
+ // Helper for Russian pluralization
54
+ const getRussianPlural = (count) => {
55
+ const lastDigit = count % 10;
56
+ const lastTwoDigits = count % 100;
57
+ if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
58
+ return 'раз';
59
+ }
60
+ if (lastDigit === 1) {
61
+ return 'раз';
62
+ }
63
+ if (lastDigit >= 2 && lastDigit <= 4) {
64
+ return 'раза';
65
+ }
66
+ return 'раз';
67
+ };
68
+
69
+ // Color assignment based on word frequency: purple gradient
70
+ const getWordColor = (count) => {
71
+ const scale = maxCount === minCount ? 1 : (count - minCount) / (maxCount - minCount);
72
+ // Scale saturation from 35% (desaturated/white-lavender) to 100% (saturated purple)
73
+ const sat = Math.round(35 + scale * 65);
74
+ // Scale lightness from 92% (light/white-ish) to 60% (vivid deep purple)
75
+ const light = Math.round(92 - scale * 32);
76
+ return `hsl(265, ${sat}%, ${light}%)`;
77
+ };
78
+
79
+ // Layout calculations with collision avoidance on stretched elliptical spiral
80
+ const placedBoxes = [];
81
+ const laidOutWords = [];
82
+
83
+ const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
84
+ const stretchX = isMobile ? 1.4 : 2.8;
85
+ const stretchY = 1.0;
86
+ const paddingX = isMobile ? 6 : 12;
87
+ const paddingY = isMobile ? 4 : 8;
88
+ const fontScale = isMobile ? 0.45 : 1.0;
89
+ const remToPx = 16;
90
+
91
+ const centerGroupThreshold = wordType === 'chat' ? 250 : 500;
92
+ // Identify center group: words with count within threshold of the max count,
93
+ // but ONLY if the top count is >= threshold. Also cap center group size to at most 3 words.
94
+ const centerGroupWords = currentWordsList.filter((w, idx) =>
95
+ idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3)
96
+ );
97
+ const otherWordsList = currentWordsList.filter((w, idx) =>
98
+ !(idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3))
99
+ );
100
+
101
+ const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1;
102
+ const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1;
103
+ const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count));
104
+
105
+ // Function to estimate word width factor based on wide/narrow letters
106
+ const getWordWidthFactor = (word) => {
107
+ let factor = 0;
108
+ const wideChars = /[мжшщыюяwm]/i;
109
+ const narrowChars = /[ilj1!|т]/i;
110
+ for (const char of word) {
111
+ if (wideChars.test(char)) {
112
+ factor += 0.75;
113
+ } else if (narrowChars.test(char)) {
114
+ factor += 0.35;
115
+ } else {
116
+ factor += 0.55;
117
+ }
118
+ }
119
+ return factor;
120
+ };
121
+
122
+ for (let i = 0; i < currentWordsList.length; i++) {
123
+ const w = currentWordsList[i];
124
+ const isCenterGroup = i === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && i < 3);
125
+
126
+ let fontSize;
127
+ let fontWeight;
128
+ let scaleValue = 0;
129
+
130
+ // Scale down the entire galaxy if the absolute max frequency is low (e.g. less than 200)
131
+ // If maxCount is 20, galaxyScale is ~0.65. If 200+, it's 1.0.
132
+ const galaxyScale = Math.min(Math.max(maxCount / 200, 0), 1.0) * 0.35 + 0.65;
133
+
134
+ if (isCenterGroup) {
135
+ // Scale font size within center group: ranges from 4.5rem to 6.2rem
136
+ const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount);
137
+ fontSize = 4.5 + centerScale * 1.7;
138
+ fontWeight = '900';
139
+ scaleValue = centerScale;
140
+ } else {
141
+ // Scale font size within other words: ranges from 1.1rem to 3.2rem
142
+ const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount);
143
+ scaleValue = Math.pow(scale, 1.4);
144
+ fontSize = 1.1 + scaleValue * 2.1;
145
+ fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500');
146
+ }
147
+
148
+ fontSize = fontSize * fontScale * galaxyScale;
149
+
150
+ // Precise width calculation based on custom width factors
151
+ const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx;
152
+ const wordHeight = fontSize * 1.1 * remToPx;
153
+
154
+ let x = 0;
155
+ let y = 0;
156
+
157
+ // Generate a stable hash for the word to randomize angle and jitter
158
+ let hash = 0;
159
+ for (let j = 0; j < w.word.length; j++) {
160
+ hash = w.word.charCodeAt(j) + ((hash << 5) - hash);
161
+ }
162
+ hash = Math.abs(hash);
163
+
164
+ // Search for position starting from r = 0.
165
+ // Since center group words are processed first, they cluster tightly around (0,0).
166
+ let found = false;
167
+ const rStep = 1.1;
168
+
169
+ for (let attempt = 0; attempt < 1200; attempt++) {
170
+ const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI
171
+ const angle = startAngle + attempt * 0.15;
172
+ const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt);
173
+
174
+ x = r * Math.cos(angle) * stretchX;
175
+ y = r * Math.sin(angle) * stretchY;
176
+
177
+ // Add coordinate noise/jitter (except for the absolute top word at the center)
178
+ if (r > 0) {
179
+ x += Math.sin(hash * 0.5 + attempt) * 6;
180
+ y += Math.cos(hash * 0.8 + attempt) * 4;
181
+ }
182
+
183
+ let collision = false;
184
+ for (const box of placedBoxes) {
185
+ const halfW1 = wordWidth / 2;
186
+ const halfH1 = wordHeight / 2;
187
+ const halfW2 = box.w / 2;
188
+ const halfH2 = box.h / 2;
189
+
190
+ if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) &&
191
+ Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) {
192
+ collision = true;
193
+ break;
194
+ }
195
+ }
196
+
197
+ if (!collision) {
198
+ found = true;
199
+ break;
200
+ }
201
+ }
202
+
203
+ placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight });
204
+
205
+ const color = getWordColor(w.word_count);
206
+
207
+ // Floating parameters
208
+ const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s
209
+ const delay = -((i * 1.3) % 7); // negative delay to start asynchronously
210
+ const amount = 3 + (i % 4); // 3px to 6px float amount
211
+
212
+ laidOutWords.push({
213
+ word: w.word,
214
+ count: w.word_count,
215
+ x: Math.round(x),
216
+ y: Math.round(y),
217
+ fontSize: `${fontSize}rem`,
218
+ fontWeight: fontWeight,
219
+ color: color,
220
+ scale: scaleValue,
221
+ isCenterGroup: isCenterGroup,
222
+ duration: `${duration}s`,
223
+ delay: `${delay}s`,
224
+ amount: `${amount}px`
225
+ });
226
+ }
227
+
228
+ return (
229
+ <div className="panel tab-content">
230
+ <div className="panel-header">
231
+ <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3>
232
+
233
+ <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}>
234
+ <button
235
+ className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`}
236
+ onClick={() => setWordType('voice')}
237
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
238
+ >
239
+ <Volume2 size={14} /> Из Голоса (Whisper)
240
+ </button>
241
+ <button
242
+ className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`}
243
+ onClick={() => setWordType('chat')}
244
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
245
+ >
246
+ <MessageSquare size={14} /> Из Чата (Текстом)
247
+ </button>
248
+ </div>
249
+ </div>
250
+
251
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}>
252
+ {wordType === 'voice'
253
+ ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).'
254
+ : 'Слова, которые зрители написали в текстовый чат Twitch.'}
255
+ </p>
256
+
257
+ <div className="word-galaxy-container">
258
+ {laidOutWords.map((w, idx) => (
259
+ <div
260
+ key={idx}
261
+ className="word-galaxy-tag"
262
+ style={{
263
+ '--x': `${w.x}px`,
264
+ '--y': `${w.y}px`,
265
+ '--float-duration': w.duration,
266
+ '--float-delay': w.delay,
267
+ '--float-amount': w.amount,
268
+ fontSize: w.fontSize,
269
+ fontWeight: w.fontWeight,
270
+ color: w.color,
271
+ opacity: 0.95,
272
+ zIndex: w.isCenterGroup ? 25 : Math.round(10 + w.scale * 15)
273
+ }}
274
+ >
275
+ {w.word}
276
+ <div className="word-tooltip">
277
+ {w.count} {getRussianPlural(w.count)}
278
+ </div>
279
+ </div>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
local_worker/processed_chat_streams.txt CHANGED
@@ -9,3 +9,4 @@
9
  13
10
  23
11
  22
 
 
9
  13
10
  23
11
  22
12
+ 31
netlify.toml CHANGED
@@ -6,7 +6,7 @@
6
  [[headers]]
7
  for = "/*"
8
  [headers.values]
9
- Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https://* http://localhost:*; connect-src 'self' https://crambrodev-winx-prinx-api.hf.space ws://localhost:* http://localhost:* https://*; frame-ancestors 'none';"
10
  X-Frame-Options = "DENY"
11
  X-Content-Type-Options = "nosniff"
12
  Referrer-Policy = "strict-origin-when-cross-origin"
 
6
  [[headers]]
7
  for = "/*"
8
  [headers.values]
9
+ Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https://* http://localhost:*; connect-src 'self' https://*.hf.space https://*.onrender.com https://* http://localhost:* ws://localhost:* wss://*; frame-ancestors 'none';"
10
  X-Frame-Options = "DENY"
11
  X-Content-Type-Options = "nosniff"
12
  Referrer-Policy = "strict-origin-when-cross-origin"