nicolaydef commited on
Commit
9ae8ed0
·
verified ·
1 Parent(s): 6c78545

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +337 -171
static/script.js CHANGED
@@ -1,172 +1,338 @@
1
- document.addEventListener('DOMContentLoaded', async () => {
2
- let supabase;
3
- let userId = 'user_' + Math.random().toString(36).substr(2, 9);
4
- try {
5
- const configRes = await fetch('/api/config');
6
- const config = await configRes.json();
7
- if (config.supabase_url) supabase = window.supabase.createClient(config.supabase_url, config.supabase_key);
8
- } catch (e) { console.error("Config Error", e); }
9
- const chatWindow = document.getElementById('chat-window');
10
- const form = document.getElementById('message-form');
11
- const input = document.getElementById('message-input');
12
- const fileInput = document.getElementById('file-input');
13
- const uploadStatus = document.getElementById('upload-status');
14
- const moodIndicator = document.getElementById('mood-indicator');
15
- const popSound = document.getElementById('pop-sound');
16
- if (supabase) {
17
- supabase.channel('room1').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, payload => {
18
- renderMessage(payload.new);
19
- playPopSound();
20
- }).subscribe();
21
- }
22
- let currentAttachment = null;
23
- fileInput.addEventListener('change', async (e) => {
24
- const file = e.target.files[0];
25
- if (!file) return;
26
- uploadStatus.classList.remove('hidden');
27
- uploadStatus.textContent = `Загрузка ${file.name}...`;
28
- const formData = new FormData();
29
- formData.append('file', file);
30
- try {
31
- const res = await fetch('/api/upload_file', { method: 'POST', body: formData });
32
- const data = await res.json();
33
- if (data.error) throw new Error(data.error);
34
- currentAttachment = data;
35
- uploadStatus.textContent = "Файл прикреплен!";
36
- uploadStatus.style.color = "#00f2ea";
37
- input.placeholder = `Прикреплен: ${file.name}. Напиши сообщение...`;
38
- input.focus();
39
- } catch (err) {
40
- console.error(err);
41
- uploadStatus.textContent = "Ошибка загрузки";
42
- uploadStatus.style.color = "#ff0055";
43
- }
44
- });
45
- form.addEventListener('submit', async (e) => {
46
- e.preventDefault();
47
- const text = input.value.trim();
48
- if (!text && !currentAttachment) return;
49
- input.value = '';
50
- input.placeholder = "Транслируй свои мысли...";
51
- uploadStatus.classList.add('hidden');
52
- let moodData = { mood_color: '#ffffff', emoji: '🌫️' };
53
- if (text) {
54
- try {
55
- const res = await fetch('/api/analyze_mood', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
56
- moodData = await res.json();
57
- updateTheme(moodData.mood_color, moodData.emoji);
58
- } catch (err) { console.error(err); }
59
- }
60
- if (supabase) {
61
- const payload = { content: text, user_id: userId, mood_color: moodData.mood_color, attachment_url: currentAttachment ? currentAttachment.url : null, attachment_type: currentAttachment ? currentAttachment.mime : null };
62
- const { error } = await supabase.from('messages').insert(payload);
63
- if (error) console.error(error);
64
- }
65
- currentAttachment = null;
66
- fileInput.value = '';
67
- });
68
-
69
- // Voice / WebRTC (Audio Only)
70
- const voiceBtn = document.getElementById('voice-btn');
71
- const micOff = document.getElementById('mic-icon-off');
72
- const micOn = document.getElementById('mic-icon-on');
73
- let localStream, remoteStream, peerConnection, audioContext, analyser, dataArray, ws, isVoiceActive = false;
74
- const rtcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
75
- voiceBtn.addEventListener('click', toggleVoiceMode);
76
- async function toggleVoiceMode() { if (isVoiceActive) stopVoice(); else await startVoice(); }
77
- async function startVoice() {
78
- try {
79
- localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
80
- setupAudioVisualizer(localStream);
81
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
82
- ws = new WebSocket(`${protocol}//${window.location.host}/ws/signal`);
83
- ws.onmessage = async (event) => {
84
- const data = JSON.parse(event.data);
85
- if (!peerConnection) createPeerConnection();
86
- if (data.type === 'offer') {
87
- await peerConnection.setRemoteDescription(new RTCSessionDescription(data));
88
- const answer = await peerConnection.createAnswer();
89
- await peerConnection.setLocalDescription(answer);
90
- ws.send(JSON.stringify({ type: 'answer', sdp: answer.sdp }));
91
- } else if (data.type === 'answer') { await peerConnection.setRemoteDescription(new RTCSessionDescription(data));
92
- } else if (data.type === 'candidate') { if (data.candidate) await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); }
93
- };
94
- ws.onopen = () => { createPeerConnection(); createOffer(); };
95
- micOff.classList.add('hidden'); micOn.classList.remove('hidden'); voiceBtn.classList.add('border-cyan-400', 'shadow-[0_0_15px_rgba(0,242,234,0.5)]'); isVoiceActive = true;
96
- } catch (err) { console.error("Voice Error", err); alert("Microphone access denied"); }
97
- }
98
- function stopVoice() {
99
- if (peerConnection) peerConnection.close();
100
- if (localStream) localStream.getTracks().forEach(t => t.stop());
101
- if (ws) ws.close();
102
- peerConnection = null; localStream = null;
103
- micOff.classList.remove('hidden'); micOn.classList.add('hidden'); voiceBtn.classList.remove('border-cyan-400', 'shadow-[0_0_15px_rgba(0,242,234,0.5)]'); isVoiceActive = false;
104
- }
105
- function createPeerConnection() {
106
- peerConnection = new RTCPeerConnection(rtcConfig);
107
- localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
108
- peerConnection.ontrack = (event) => { const remoteAudio = new Audio(); remoteAudio.srcObject = event.streams[0]; remoteAudio.autoplay = true; };
109
- peerConnection.onicecandidate = (event) => { if (event.candidate) ws.send(JSON.stringify({ type: 'candidate', candidate: event.candidate })); };
110
- }
111
- async function createOffer() { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); ws.send(JSON.stringify({ type: 'offer', sdp: offer.sdp })); }
112
- function setupAudioVisualizer(stream) {
113
- if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
114
- const source = audioContext.createMediaStreamSource(stream);
115
- analyser = audioContext.createAnalyser();
116
- analyser.fftSize = 64;
117
- source.connect(analyser);
118
- dataArray = new Uint8Array(analyser.frequencyBinCount);
119
- }
120
-
121
- function renderMessage(msg) {
122
- const div = document.createElement('div');
123
- const isOwn = msg.user_id === userId;
124
- div.className = `message ${isOwn ? 'own' : 'other'}`;
125
- div.style.boxShadow = `0 0 15px ${msg.mood_color || '#fff'}40`;
126
- let html = `<div>${msg.content || ''}</div>`;
127
- if (msg.attachment_url) {
128
- if (msg.attachment_type && msg.attachment_type.startsWith('image/')) { html += `<img src="${msg.attachment_url}" alt="Image">`; }
129
- else { html += `<a href="${msg.attachment_url}" target="_blank" class="file-link">📎 Скачать файл</a>`; }
130
- }
131
- div.innerHTML = html;
132
- chatWindow.appendChild(div);
133
- chatWindow.scrollTop = chatWindow.scrollHeight;
134
- }
135
- function updateTheme(color, emoji) {
136
- document.documentElement.style.setProperty('--dynamic-bg-color', color);
137
- moodIndicator.textContent = emoji;
138
- }
139
- function playPopSound() { if(popSound) { popSound.currentTime = 0; popSound.play().catch(e=>{}); } }
140
-
141
- // --- Visuals ---
142
- const canvas = document.getElementById('particle-canvas');
143
- const ctx = canvas.getContext('2d');
144
- let particles = [];
145
- function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
146
- window.addEventListener('resize', resize); resize();
147
- class Particle {
148
- constructor() { this.reset(); }
149
- reset() { this.x = Math.random() * canvas.width; this.y = Math.random() * canvas.height; this.size = Math.random() * 2; this.speedX = Math.random() * 0.5 - 0.25; this.speedY = Math.random() * 0.5 - 0.25; this.opacity = Math.random() * 0.5; }
150
- update() { this.x += this.speedX; this.y += this.speedY; if(this.x<0 || this.x>canvas.width) this.speedX*=-1; if(this.y<0 || this.y>canvas.height) this.speedY*=-1; }
151
- draw() { ctx.fillStyle = `rgba(255,255,255,${this.opacity})`; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI*2); ctx.fill(); }
152
- }
153
- for(let i=0; i<50; i++) particles.push(new Particle());
154
- function animate() {
155
- ctx.clearRect(0,0,canvas.width,canvas.height);
156
- let audioFactor = 0;
157
- if (isVoiceActive && analyser && dataArray) {
158
- analyser.getByteFrequencyData(dataArray);
159
- audioFactor = (dataArray.reduce((a, b) => a + b) / dataArray.length) / 50;
160
- }
161
- particles.forEach(p => {
162
- if(isVoiceActive) {
163
- p.x += p.speedX * (1+audioFactor); p.y += p.speedY * (1+audioFactor);
164
- let ps = p.size + (audioFactor*2);
165
- ctx.fillStyle = `rgba(255,255,255,${p.opacity})`; ctx.beginPath(); ctx.arc(p.x, p.y, ps>0?ps:0, 0, Math.PI*2); ctx.fill();
166
- } else { p.update(); p.draw(); }
167
- if(p.x<0 || p.x>canvas.width) p.speedX*=-1; if(p.y<0 || p.y>canvas.height) p.speedY*=-1;
168
- });
169
- requestAnimationFrame(animate);
170
- }
171
- animate();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  });
 
1
+ document.addEventListener('DOMContentLoaded', async () => {
2
+ // --- STATE ---
3
+ let currentUser = null;
4
+ let currentChannel = 'general';
5
+ let supabase;
6
+ let peerConnection;
7
+ let ws;
8
+ let localStream;
9
+ let pingInterval;
10
+
11
+ // --- INIT CONFIG ---
12
+ const config = await (await fetch('/api/config')).json();
13
+ supabase = window.supabase.createClient(config.supabase_url, config.supabase_key);
14
+
15
+ // --- UI REFS ---
16
+ const loginModal = document.getElementById('login-modal');
17
+ const appInterface = document.getElementById('app-interface');
18
+ const msgContainer = document.getElementById('messages-container');
19
+
20
+ // --- 1. AUTH SYSTEM ---
21
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
22
+ e.preventDefault();
23
+ const username = document.getElementById('login-user').value.trim();
24
+ const pass = document.getElementById('login-pass').value.trim();
25
+
26
+ // Check DB
27
+ const { data: user, error } = await supabase
28
+ .from('users')
29
+ .select('*')
30
+ .eq('username', username)
31
+ .single();
32
+
33
+ if (error || !user) {
34
+ showError("USER NOT FOUND");
35
+ return;
36
+ }
37
+
38
+ // Compare Hash (Client-side verify for simplicity in this demo, usually server-side)
39
+ const isValid = dcodeIO.bcrypt.compareSync(pass, user.password_hash);
40
+
41
+ if (isValid) {
42
+ currentUser = user;
43
+ loginModal.classList.remove('open');
44
+ appInterface.classList.remove('hidden');
45
+ document.getElementById('current-username').textContent = user.username;
46
+ initApp();
47
+ } else {
48
+ showError("INVALID CREDENTIALS");
49
+ }
50
+ });
51
+
52
+ function showError(msg) {
53
+ const err = document.getElementById('login-error');
54
+ err.textContent = msg;
55
+ err.classList.remove('hidden');
56
+ }
57
+
58
+ // --- 2. ADMIN SYSTEM ---
59
+ const adminModal = document.getElementById('admin-modal');
60
+ const adminTrigger = document.getElementById('admin-trigger');
61
+
62
+ adminTrigger.addEventListener('click', () => {
63
+ adminModal.classList.add('open');
64
+ document.getElementById('admin-code').focus();
65
+ });
66
+
67
+ // Check Code Input
68
+ document.getElementById('admin-code').addEventListener('input', (e) => {
69
+ if (e.target.value.length === 4) {
70
+ // Check secret with backend (fake check first in UI, logic in backend)
71
+ // Just reveal form for UX, real check happens on submit
72
+ document.getElementById('admin-step-1').classList.add('hidden');
73
+ document.getElementById('admin-create-form').classList.remove('hidden');
74
+ }
75
+ });
76
+
77
+ // Create User
78
+ document.getElementById('admin-create-form').addEventListener('submit', async (e) => {
79
+ e.preventDefault();
80
+ const code = document.getElementById('admin-code').value;
81
+ const newU = document.getElementById('new-user').value;
82
+ const newP = document.getElementById('new-pass').value;
83
+
84
+ try {
85
+ const res = await fetch('/api/admin/create_user', {
86
+ method: 'POST',
87
+ headers: {'Content-Type': 'application/json'},
88
+ body: JSON.stringify({ admin_code: code, new_username: newU, new_password: newP })
89
+ });
90
+
91
+ if (!res.ok) throw new Error("ACCESS DENIED");
92
+
93
+ const data = await res.json();
94
+
95
+ // Push to Supabase
96
+ await supabase.from('users').insert({
97
+ username: data.username,
98
+ password_hash: data.password_hash,
99
+ badge: data.badge,
100
+ is_admin: false
101
+ });
102
+
103
+ alert(`ENTITY ${data.username} CREATED`);
104
+ adminModal.classList.remove('open');
105
+ // Reset form
106
+ document.getElementById('admin-create-form').reset();
107
+ document.getElementById('admin-step-1').classList.remove('hidden');
108
+ document.getElementById('admin-create-form').classList.add('hidden');
109
+ document.getElementById('admin-code').value = '';
110
+
111
+ } catch (err) {
112
+ alert(err.message);
113
+ document.getElementById('admin-code').value = '';
114
+ document.getElementById('admin-step-1').classList.remove('hidden');
115
+ document.getElementById('admin-create-form').classList.add('hidden');
116
+ }
117
+ });
118
+
119
+ // Close admin modal on click outside
120
+ adminModal.addEventListener('click', (e) => {
121
+ if(e.target === adminModal) adminModal.classList.remove('open');
122
+ });
123
+
124
+ // --- 3. APP LOGIC ---
125
+ function initApp() {
126
+ subscribeToMessages();
127
+ loadUsers();
128
+ }
129
+
130
+ async function subscribeToMessages() {
131
+ // Load history
132
+ const { data } = await supabase.from('messages').select('*').order('created_at', { ascending: true });
133
+ data.forEach(renderMessage);
134
+
135
+ // Realtime
136
+ supabase.channel('public:messages')
137
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, payload => {
138
+ renderMessage(payload.new);
139
+ })
140
+ .subscribe();
141
+ }
142
+
143
+ async function loadUsers() {
144
+ const list = document.getElementById('users-list');
145
+ const { data } = await supabase.from('users').select('*');
146
+
147
+ list.innerHTML = '';
148
+ data.forEach(u => {
149
+ if (u.username === currentUser.username) return;
150
+ const div = document.createElement('div');
151
+ div.className = 'channel-item text-sm opacity-70 hover:opacity-100';
152
+ div.innerHTML = `
153
+ <div class="w-2 h-2 rounded-full bg-gray-500 mr-2"></div>
154
+ ${u.username} ${u.badge === 'BETA' ? '<span class="text-yellow-500 text-[10px] ml-1">★</span>' : ''}
155
+ `;
156
+ list.appendChild(div);
157
+ });
158
+ }
159
+
160
+ // --- 4. MESSAGING ---
161
+ const form = document.getElementById('chat-form');
162
+ form.addEventListener('submit', async (e) => {
163
+ e.preventDefault();
164
+ const input = document.getElementById('msg-input');
165
+ const text = input.value.trim();
166
+ if (!text) return;
167
+ input.value = '';
168
+
169
+ // Mood Analysis
170
+ const moodRes = await fetch('/api/analyze_mood', {
171
+ method: 'POST',
172
+ headers: {'Content-Type': 'application/json'},
173
+ body: JSON.stringify({text})
174
+ });
175
+ const mood = await moodRes.json();
176
+
177
+ // Save
178
+ await supabase.from('messages').insert({
179
+ content: text,
180
+ user_id: currentUser.id,
181
+ username: currentUser.username,
182
+ mood_color: mood.mood_color,
183
+ channel_id: currentChannel
184
+ });
185
+ });
186
+
187
+ function renderMessage(msg) {
188
+ const div = document.createElement('div');
189
+
190
+ if (msg.is_system) {
191
+ div.className = 'message w-full justify-center my-2';
192
+ div.innerHTML = `<div class="system">🤖 CLEAN: ${msg.content}</div>`;
193
+ } else {
194
+ const isOwn = msg.username === currentUser.username;
195
+ div.className = `message ${isOwn ? 'flex-row-reverse self-end' : ''}`;
196
+
197
+ // Badge Logic
198
+ // Note: In real app we fetch user badge, here we assume all are beta for demo visual
199
+ const badgeHtml = `<span class="badge-beta">BETA</span>`;
200
+
201
+ div.innerHTML = `
202
+ <div class="flex flex-col ${isOwn ? 'items-end' : 'items-start'}">
203
+ <div class="text-[10px] text-white/40 mb-1 px-1">
204
+ ${msg.username} ${badgeHtml}
205
+ </div>
206
+ <div class="message-content" style="border-left: 3px solid ${msg.mood_color || '#fff'}">
207
+ ${msg.content}
208
+ </div>
209
+ </div>
210
+ `;
211
+ }
212
+ msgContainer.appendChild(div);
213
+ msgContainer.scrollTop = msgContainer.scrollHeight;
214
+ }
215
+
216
+ // --- 5. WEBRTC PRO (Discord style) ---
217
+ const voiceBtn = document.getElementById('voice-toggle');
218
+ const voiceUI = document.getElementById('voice-ui');
219
+ const voiceDot = document.getElementById('voice-dot');
220
+ const voiceText = document.getElementById('voice-text');
221
+ const voicePing = document.getElementById('voice-ping');
222
+ let isVoiceActive = false;
223
+
224
+ voiceBtn.addEventListener('click', async () => {
225
+ if (isVoiceActive) disconnectVoice();
226
+ else await connectVoice();
227
+ });
228
+
229
+ async function connectVoice() {
230
+ isVoiceActive = true;
231
+ voiceUI.classList.remove('hidden');
232
+ updateVoiceStatus('connecting', 'Connecting to RTC...');
233
+
234
+ try {
235
+ localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
236
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
237
+ ws = new WebSocket(`${protocol}//${window.location.host}/ws/signal`);
238
+
239
+ peerConnection = new RTCPeerConnection({
240
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
241
+ });
242
+
243
+ // Add Tracks
244
+ localStream.getTracks().forEach(t => peerConnection.addTrack(t, localStream));
245
+
246
+ // On Track
247
+ peerConnection.ontrack = e => {
248
+ const audio = new Audio();
249
+ audio.srcObject = e.streams[0];
250
+ audio.play();
251
+ };
252
+
253
+ // Signaling
254
+ ws.onmessage = async (e) => {
255
+ const data = JSON.parse(e.data);
256
+ if (data.type === 'offer') {
257
+ await peerConnection.setRemoteDescription(data);
258
+ const answer = await peerConnection.createAnswer();
259
+ await peerConnection.setLocalDescription(answer);
260
+ ws.send(JSON.stringify(peerConnection.localDescription));
261
+ } else if (data.type === 'answer') {
262
+ await peerConnection.setRemoteDescription(data);
263
+ } else if (data.candidate) {
264
+ await peerConnection.addIceCandidate(data.candidate);
265
+ }
266
+ };
267
+
268
+ peerConnection.oniceconnectionstatechange = () => {
269
+ const state = peerConnection.iceConnectionState;
270
+ if (state === 'connected' || state === 'completed') {
271
+ updateVoiceStatus('connected', 'Voice Connected');
272
+ startPingStats();
273
+ } else if (state === 'failed' || state === 'disconnected') {
274
+ botCleanReport("Connection lost to RTC Server.");
275
+ disconnectVoice();
276
+ }
277
+ };
278
+
279
+ ws.onopen = async () => {
280
+ const offer = await peerConnection.createOffer();
281
+ await peerConnection.setLocalDescription(offer);
282
+ ws.send(JSON.stringify(offer));
283
+ };
284
+
285
+ } catch (e) {
286
+ console.error(e);
287
+ botCleanReport("Failed to initialize audio device.");
288
+ disconnectVoice();
289
+ }
290
+ }
291
+
292
+ function disconnectVoice() {
293
+ isVoiceActive = false;
294
+ voiceUI.classList.add('hidden');
295
+ if (peerConnection) peerConnection.close();
296
+ if (ws) ws.close();
297
+ if (localStream) localStream.getTracks().forEach(t => t.stop());
298
+ clearInterval(pingInterval);
299
+ voiceBtn.classList.remove('bg-red-500/20');
300
+ }
301
+
302
+ function updateVoiceStatus(state, text) {
303
+ voiceDot.className = `status-dot ${state}`;
304
+ voiceText.textContent = text;
305
+ if (state === 'connected') {
306
+ voiceBtn.classList.add('bg-green-500/20');
307
+ document.getElementById('mic-icon').classList.add('text-green-400');
308
+ }
309
+ }
310
+
311
+ // --- PING SYSTEM ---
312
+ function startPingStats() {
313
+ pingInterval = setInterval(async () => {
314
+ if (!peerConnection) return;
315
+ const stats = await peerConnection.getStats();
316
+ let rtt = 0;
317
+ stats.forEach(report => {
318
+ if (report.type === 'candidate-pair' && report.state === 'succeeded') {
319
+ rtt = report.currentRoundTripTime * 1000; // ms
320
+ }
321
+ });
322
+ if (rtt > 0) {
323
+ voicePing.textContent = `${Math.round(rtt)}ms`;
324
+ if (rtt > 200) voicePing.style.color = 'red';
325
+ else voicePing.style.color = 'lime';
326
+ }
327
+ }, 1000);
328
+ }
329
+
330
+ // --- BOT CLEAN ---
331
+ async function botCleanReport(errorMsg) {
332
+ await supabase.from('messages').insert({
333
+ content: `ERR_RTC: ${errorMsg}`,
334
+ is_system: true,
335
+ channel_id: currentChannel
336
+ });
337
+ }
338
  });