MetiMiester commited on
Commit
d33cc84
·
verified ·
1 Parent(s): 81c8695

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +417 -420
index.html CHANGED
@@ -1,420 +1,417 @@
1
- <!doctype html>
2
- <html lang="en" data-bs-theme="light">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
- <title>BubbleGuard – Safe Chat</title>
7
- <link rel="icon" href="/logo.png">
8
- <!-- Bootstrap 5.3 -->
9
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
10
- <!-- Apple-style theme -->
11
- <link href="/styles.css" rel="stylesheet">
12
- </head>
13
- <body>
14
- <div class="app">
15
-
16
- <!-- Header -->
17
- <header class="glass py-2 px-3">
18
- <div class="container-fluid d-flex align-items-center gap-3">
19
- <button class="btn btn-ico" type="button" aria-label="Back" title="Back">‹</button>
20
- <div class="d-flex align-items-center gap-2">
21
- <img src="/logo.png" alt="" class="rounded-3 header-logo" onerror="this.style.display='none'">
22
- <div class="d-flex flex-column lh-1">
23
- <div class="app-title">BubbleGuard</div>
24
- <div id="health" class="subtle" aria-live="polite">Checking…</div>
25
- </div>
26
- </div>
27
- <div class="ms-auto d-flex align-items-center gap-1">
28
- <button id="theme" class="btn btn-ico" type="button" aria-label="Toggle theme" title="Appearance">🌓</button>
29
- </div>
30
- </div>
31
- </header>
32
-
33
- <!-- Chat -->
34
- <main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
35
- <!-- Greeting -->
36
- <div class="row-start">
37
- <img src="/avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
38
- <div class="bubble-them bubble shadow-bubble">
39
- <div class="copy">Hey there 💖 Welcome to BubbleGuard’s safe chat! Share pics, voice notes, or messages — we’ll keep it kind.</div>
40
- <div class="meta">now</div>
41
- </div>
42
- </div>
43
- </main>
44
-
45
- <!-- Composer -->
46
- <footer class="composer-wrap">
47
-
48
- <!-- Reply banner -->
49
- <div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
50
- <div class="rb-body">
51
- <div class="rb-line">
52
- <span class="rb-label">Replying to</span>
53
- <span id="replySnippet" class="rb-snippet"></span>
54
- </div>
55
- <button id="replyCancel" class="btn-ico rb-close" aria-label="Cancel reply">✕</button>
56
- </div>
57
- </div>
58
-
59
- <div class="container composer">
60
- <input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
61
-
62
- <button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image">+</button>
63
-
64
- <div class="input-shell">
65
- <!-- UPDATED placeholder text here -->
66
- <textarea id="input" rows="1" placeholder="Write a message here…" class="form-control input-ios" aria-label="Message input"></textarea>
67
- <div id="typing" class="typing d-none" aria-hidden="true">
68
- <span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
69
- </div>
70
- </div>
71
-
72
- <div class="audio-controls d-flex align-items-center gap-1">
73
- <button id="btnStart" class="btn btn-ico" title="Record" aria-label="Start recording">🎤</button>
74
- <button id="btnStop" class="btn btn-ico" title="Stop" aria-label="Stop recording" disabled>⏹</button>
75
- <span id="recTimer" class="pill subtle d-none" aria-live="polite">00:00</span>
76
- </div>
77
-
78
- <button id="btnSend" class="btn send-ios" aria-label="Send">↑</button>
79
- </div>
80
-
81
- <!-- Toast -->
82
- <div class="toast-zone">
83
- <div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
84
- <div class="toast-body" id="toastBody">Hello</div>
85
- </div>
86
- </div>
87
- </footer>
88
- </div>
89
-
90
- <!-- Bootstrap JS -->
91
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
92
-
93
- <!-- App Script -->
94
- <script>
95
- // ---------- Config ----------
96
- const baseURL = ''; // same-origin; set full origin if tunneling under a subpath
97
- const api = (p) => baseURL + p;
98
-
99
- // ---------- Theme ----------
100
- const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
101
- const saved = localStorage.getItem('bg-theme');
102
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
103
- setTheme(saved || (prefersDark ? 'dark' : 'light'));
104
- document.getElementById('theme').onclick = () => {
105
- const cur = document.documentElement.getAttribute('data-bs-theme');
106
- const next = cur === 'dark' ? 'light' : 'dark';
107
- setTheme(next); localStorage.setItem('bg-theme', next);
108
- };
109
-
110
- // ---------- Health ----------
111
- (async () => {
112
- try {
113
- const r = await fetch(api('/health'));
114
- if (!r.ok) throw 0;
115
- const j = await r.json();
116
- const t = j.text_thresholds || {};
117
- const h = document.getElementById('health');
118
- h.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
119
- } catch {
120
- document.getElementById('health').textContent = 'Offline';
121
- }
122
- })();
123
-
124
- // ---------- DOM helpers ----------
125
- const $ = (id)=>document.getElementById(id);
126
- const chat = $('chat'), input=$('input'), typing=$('typing');
127
- const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
128
- const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
129
- const toastEl = $('toast'), toastBody = $('toastBody');
130
- const toast = new bootstrap.Toast(toastEl, { delay: 4200 });
131
-
132
- const timeNow = () => new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
133
- const scrollBottom = () => { chat.scrollTop = chat.scrollHeight; };
134
- const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
135
- const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
136
-
137
- // ---------- Reactions & Reply state ----------
138
- const REACTIONS = ["👍","❤️","😂","😮","😢"];
139
- let replyTarget = null; // { el, text }
140
-
141
- function markDelivered(bubble, double=false){
142
- const meta = bubble.querySelector('.meta');
143
- if(!meta) return;
144
- let ticks = meta.querySelector('.ticks');
145
- if(!ticks){
146
- ticks = document.createElement('span');
147
- ticks.className = 'ticks';
148
- meta.appendChild(ticks);
149
- }
150
- ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
151
- }
152
-
153
- function showReactionsPop(bubble){
154
- hideReactionsPop();
155
- const pop = document.createElement('div');
156
- pop.className = 'react-pop';
157
- pop.setAttribute('role','menu');
158
- REACTIONS.forEach(e=>{
159
- const b=document.createElement('button');
160
- b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
161
- b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
162
- pop.appendChild(b);
163
- });
164
- bubble.appendChild(pop);
165
- setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
166
- }
167
- function hideReactionsPop(){
168
- document.querySelectorAll('.react-pop').forEach(p=>p.remove());
169
- }
170
-
171
- function toggleReaction(bubble, emoji){
172
- bubble._reactions = bubble._reactions || new Map();
173
- const meKey = `me:${emoji}`;
174
- if(bubble._reactions.has(meKey)) bubble._reactions.delete(meKey);
175
- else bubble._reactions.set(meKey, 1);
176
- renderReactions(bubble);
177
- }
178
- function renderReactions(bubble){
179
- const counts = {};
180
- (bubble._reactions||new Map()).forEach((v,k)=>{
181
- const em = k.split(':')[1];
182
- counts[em] = (counts[em]||0) + 1;
183
- });
184
- let row = bubble.querySelector('.react-row');
185
- if(!row){
186
- row = document.createElement('div'); row.className='react-row';
187
- bubble.appendChild(row);
188
- }
189
- row.innerHTML = '';
190
- Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
191
- const chip = document.createElement('span');
192
- chip.className='react-chip';
193
- chip.innerHTML = `${em} <span class="count">${c}</span>`;
194
- row.appendChild(chip);
195
- });
196
- if(Object.keys(counts).length===0) row.remove();
197
- }
198
-
199
- function startReply(bubble){
200
- const textNode = bubble.querySelector('.copy')?.textContent ?? '';
201
- replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
202
- $('replySnippet').textContent = replyTarget.text || '(media)';
203
- $('replyBanner').classList.remove('d-none');
204
- bubble.classList.add('swipe-hint');
205
- setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
206
- }
207
- function cancelReply(){
208
- replyTarget = null;
209
- $('replyBanner').classList.add('d-none');
210
- }
211
- $('replyCancel').onclick = cancelReply;
212
-
213
- // ---------- Bubble creators ----------
214
- function armBubbleInteractions(bubble, isThem=false){
215
- // Reactions: long-press / right-click
216
- let pressTimer = null;
217
- const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
218
- const endPress = ()=>{ clearTimeout(pressTimer); };
219
-
220
- bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
221
- bubble.addEventListener('pointerdown', startPress);
222
- bubble.addEventListener('pointerup', endPress);
223
- bubble.addEventListener('pointerleave', endPress);
224
-
225
- // Swipe-to-reply on "them" bubbles (mobile)
226
- if(isThem){
227
- let sx=0, dx=0;
228
- bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
229
- bubble.addEventListener('touchmove', (e)=>{
230
- dx = e.touches[0].clientX - sx;
231
- if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`;
232
- }, {passive:true});
233
- bubble.addEventListener('touchend', ()=>{
234
- if(dx>56) startReply(bubble);
235
- bubble.style.transform = '';
236
- });
237
- }
238
- }
239
-
240
- function bubbleMe(html) {
241
- const row = document.createElement('div'); row.className='row-end';
242
- row.innerHTML = `
243
- <div class="bubble-you bubble shadow-bubble">
244
- <div class="copy">${html}</div>
245
- <div class="meta">${timeNow()}</div>
246
- </div>
247
- <img src="/avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
248
- `;
249
- chat.appendChild(row); scrollBottom();
250
- const b = row.querySelector('.bubble-you');
251
- armBubbleInteractions(b, false);
252
- setTimeout(()=>markDelivered(b, true), 450);
253
- return b;
254
- }
255
-
256
- function bubbleThem(html) {
257
- const row = document.createElement('div'); row.className='row-start';
258
- row.innerHTML = `
259
- <img src="/avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
260
- <div class="bubble-them bubble shadow-bubble">
261
- <div class="copy">${html}</div>
262
- <div class="meta">${timeNow()}</div>
263
- </div>
264
- `;
265
- chat.appendChild(row); scrollBottom();
266
- const b = row.querySelector('.bubble-them');
267
- armBubbleInteractions(b, true);
268
- return b;
269
- }
270
-
271
- // ---------- Blast helper (block animation) ----------
272
- function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
273
- const content = preview
274
- ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
275
- : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
276
- const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
277
- bubble.classList.add('bubble-blast');
278
- setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
279
- }
280
-
281
- // ---------- Input behavior ----------
282
- function setTyping(on){ typing.classList.toggle('d-none', !on); }
283
- input.addEventListener('input', ()=>{
284
- input.style.height='auto';
285
- input.style.height=Math.min(input.scrollHeight, 140)+'px';
286
- });
287
- input.addEventListener('keydown',(e)=>{
288
- if(e.key==='Escape'){ input.value=''; input.style.height='auto'; }
289
- if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendText(); }
290
- });
291
-
292
- // Normalize curly quotes/apostrophes BEFORE sending
293
- function normalizeInputText(text){
294
- return text.replace(/[’‘]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim();
295
- }
296
-
297
- // ---------- Send text ----------
298
- async function sendText(){
299
- let t = input.value;
300
- t = normalizeInputText(t);
301
- if(!t) return;
302
-
303
- // If replying, prefix a quote (client-side only)
304
- if(replyTarget){
305
- const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : '';
306
- t = `${quoted}${t}`;
307
- }
308
-
309
- setTyping(true); btnSend.disabled=true;
310
- try{
311
- const fd = new FormData(); fd.append('text', t);
312
- const r = await fetch(api('/check_text'),{method:'POST', body:fd});
313
- const j = await r.json();
314
-
315
- if (r.ok && j && typeof j.safe !== 'undefined') {
316
- if (j.safe) {
317
- bubbleMe(esc(t));
318
- cancelReply();
319
- } else {
320
- blastBubble({ html: esc(t), side: 'you', preview: true, icon: '🚫' });
321
- const reason = j.reason ? ` (${j.reason}${j.unsafe_prob!=null?` · p=${(+j.unsafe_prob).toFixed(2)}`:''})` : '';
322
- showToast('Message blocked as unsafe' + reason);
323
- }
324
- } else {
325
- showToast('Unexpected response');
326
- }
327
- }catch(e){ showToast('Error: '+e); }
328
- finally{
329
- input.value=''; input.style.height='auto';
330
- setTyping(false); btnSend.disabled=false;
331
- }
332
- }
333
- btnSend.onclick = sendText;
334
-
335
- // ---------- Image: click & drag-drop ----------
336
- btnImg.onclick = ()=> fileImg.click();
337
- fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
338
-
339
- async function handleImage(file){
340
- setTyping(true);
341
- try{
342
- const fd = new FormData(); fd.append('file', file);
343
- const r = await fetch(api('/check_image'),{method:'POST', body:fd});
344
- const j = await r.json();
345
- if(j.safe){
346
- const url = URL.createObjectURL(file);
347
- bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`);
348
- } else {
349
- blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
350
- const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason);
351
- }
352
- }catch(e){ showToast('Error: '+e); }
353
- finally{ setTyping(false); }
354
- }
355
-
356
- // Drag & Drop (image only)
357
- ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
358
- ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
359
- document.addEventListener('drop', async (e)=>{
360
- const f = e.dataTransfer?.files?.[0];
361
- if(!f) return;
362
- if (f.type.startsWith('image/')) await handleImage(f);
363
- else showToast('Only images supported via drop.');
364
- }, false);
365
-
366
- // ---------- Voice ----------
367
- let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
368
- const pickMime = () => {
369
- const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg'];
370
- for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
371
- return '';
372
- };
373
- async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
374
- function setRecUI(recording){
375
- btnStart.disabled=recording; btnStop.disabled=!recording;
376
- recTimer.classList.toggle('d-none',!recording);
377
- }
378
- const fmt = (t)=>{ const m=Math.floor(t/60), s=Math.floor(t%60); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; };
379
-
380
- btnStart.onclick = async ()=>{
381
- try{
382
- await ensureMic(); chunks=[];
383
- const mime = pickMime();
384
- mediaRecorder = new MediaRecorder(mediaStream, mime? {mimeType:mime}:{});
385
- mediaRecorder.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
386
- mediaRecorder.onstop = onRecordingStop;
387
- mediaRecorder.start(250);
388
- startTs = Date.now();
389
- tick = setInterval(()=> recTimer.textContent = fmt((Date.now()-startTs)/1000), 300);
390
- setRecUI(true);
391
- setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); }, 60000);
392
- }catch(e){ showToast('Mic error: '+e); }
393
- };
394
- btnStop.onclick = ()=>{
395
- if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); }
396
- if(tick){ clearInterval(tick); tick=null; }
397
- setRecUI(false);
398
- };
399
-
400
- async function onRecordingStop(){
401
- try{
402
- setTyping(true);
403
- const type = mediaRecorder?.mimeType || 'audio/webm';
404
- const blob = new Blob(chunks, { type });
405
- const fd = new FormData(); fd.append('file', blob, 'voice');
406
- const r = await fetch(api('/check_audio'), { method:'POST', body: fd });
407
- const j = await r.json();
408
- if(j.safe){
409
- const url = URL.createObjectURL(blob);
410
- bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`);
411
- }else{
412
- blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
413
- const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason);
414
- }
415
- }catch(e){ showToast('Error: '+e); }
416
- finally{ setTyping(false); }
417
- }
418
- </script>
419
- </body>
420
- </html>
 
1
+ <!doctype html>
2
+ <html lang="en" data-bs-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
+ <title>BubbleGuard – Safe Chat</title>
7
+
8
+ <!-- Favicon & CSS: use RELATIVE paths (no leading /) -->
9
+ <link rel="icon" href="logo.png">
10
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
11
+ <link href="styles.css" rel="stylesheet">
12
+ </head>
13
+ <body>
14
+ <div class="app">
15
+
16
+ <!-- Header -->
17
+ <header class="glass py-2 px-3">
18
+ <div class="container-fluid d-flex align-items-center gap-3">
19
+ <button class="btn btn-ico" type="button" aria-label="Back" title="Back">‹</button>
20
+ <div class="d-flex align-items-center gap-2">
21
+ <img src="logo.png" alt="" class="rounded-3 header-logo" onerror="this.style.display='none'">
22
+ <div class="d-flex flex-column lh-1">
23
+ <div class="app-title">BubbleGuard</div>
24
+ <div id="health" class="subtle" aria-live="polite">Checking…</div>
25
+ </div>
26
+ </div>
27
+ <div class="ms-auto d-flex align-items-center gap-1">
28
+ <button id="theme" class="btn btn-ico" type="button" aria-label="Toggle theme" title="Appearance">🌓</button>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <!-- Chat -->
34
+ <main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
35
+ <!-- Greeting -->
36
+ <div class="row-start">
37
+ <img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
38
+ <div class="bubble-them bubble shadow-bubble">
39
+ <div class="copy">Hey there 💖 Welcome to BubbleGuard’s safe chat! Share pics, voice notes, or messages — we’ll keep it kind.</div>
40
+ <div class="meta">now</div>
41
+ </div>
42
+ </div>
43
+ </main>
44
+
45
+ <!-- Composer -->
46
+ <footer class="composer-wrap">
47
+
48
+ <!-- Reply banner -->
49
+ <div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
50
+ <div class="rb-body">
51
+ <div class="rb-line">
52
+ <span class="rb-label">Replying to</span>
53
+ <span id="replySnippet" class="rb-snippet"></span>
54
+ </div>
55
+ <button id="replyCancel" class="btn-ico rb-close" aria-label="Cancel reply">✕</button>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="container composer">
60
+ <input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
61
+
62
+ <button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image">+</button>
63
+
64
+ <div class="input-shell">
65
+ <textarea id="input" rows="1" placeholder="Write a message here…" class="form-control input-ios" aria-label="Message input"></textarea>
66
+ <div id="typing" class="typing d-none" aria-hidden="true">
67
+ <span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="audio-controls d-flex align-items-center gap-1">
72
+ <button id="btnStart" class="btn btn-ico" title="Record" aria-label="Start recording">🎤</button>
73
+ <button id="btnStop" class="btn btn-ico" title="Stop" aria-label="Stop recording" disabled>⏹</button>
74
+ <span id="recTimer" class="pill subtle d-none" aria-live="polite">00:00</span>
75
+ </div>
76
+
77
+ <button id="btnSend" class="btn send-ios" aria-label="Send">↑</button>
78
+ </div>
79
+
80
+ <!-- Toast -->
81
+ <div class="toast-zone">
82
+ <div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
83
+ <div class="toast-body" id="toastBody">Hello</div>
84
+ </div>
85
+ </div>
86
+ </footer>
87
+ </div>
88
+
89
+ <!-- Bootstrap JS -->
90
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
91
+
92
+ <!-- App Script -->
93
+ <script>
94
+ // ---------- Config (RELATIVE API helper) ----------
95
+ const api = (p) => new URL(p, window.location.href).toString(); // use: api('check_text')
96
+
97
+ // ---------- JSON guard ----------
98
+ async function fetchJSON(url, opts) {
99
+ const res = await fetch(url, Object.assign({ headers: { 'Accept': 'application/json' } }, opts || {}));
100
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
101
+ const text = await res.text();
102
+ if (!ct.includes('application/json')) {
103
+ throw new Error('Non-JSON from API: ' + text.slice(0, 160));
104
+ }
105
+ let body;
106
+ try { body = JSON.parse(text); } catch (e) {
107
+ throw new Error('Invalid JSON: ' + text.slice(0, 160));
108
+ }
109
+ if (!res.ok) {
110
+ const msg = (body && (body.detail || body.error)) || (res.status + ' ' + res.statusText);
111
+ throw new Error(msg);
112
+ }
113
+ return body;
114
+ }
115
+
116
+ // ---------- Theme ----------
117
+ const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
118
+ const saved = localStorage.getItem('bg-theme');
119
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
120
+ setTheme(saved || (prefersDark ? 'dark' : 'light'));
121
+ document.getElementById('theme').onclick = () => {
122
+ const cur = document.documentElement.getAttribute('data-bs-theme');
123
+ const next = cur === 'dark' ? 'light' : 'dark';
124
+ setTheme(next); localStorage.setItem('bg-theme', next);
125
+ };
126
+
127
+ // ---------- Health ----------
128
+ (async () => {
129
+ try {
130
+ const j = await fetchJSON(api('health'));
131
+ const t = j.text_thresholds || {};
132
+ document.getElementById('health').textContent =
133
+ `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
134
+ } catch (e) {
135
+ document.getElementById('health').textContent = 'Offline';
136
+ console.warn('health error:', e);
137
+ }
138
+ })();
139
+
140
+ // ---------- DOM helpers ----------
141
+ const $ = (id)=>document.getElementById(id);
142
+ const chat = $('chat'), input=$('input'), typing=$('typing');
143
+ const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
144
+ const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
145
+ const toastEl = $('toast'), toastBody = $('toastBody');
146
+ const toast = new bootstrap.Toast(toastEl, { delay: 4200 });
147
+
148
+ const timeNow = () => new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
149
+ const scrollBottom = () => { chat.scrollTop = chat.scrollHeight; };
150
+ const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
151
+ const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
152
+
153
+ // ---------- Reactions & Reply state ----------
154
+ const REACTIONS = ["👍","❤️","😂","😮","😢"];
155
+ let replyTarget = null;
156
+
157
+ function markDelivered(bubble, double=false){
158
+ const meta = bubble.querySelector('.meta');
159
+ if(!meta) return;
160
+ let ticks = meta.querySelector('.ticks');
161
+ if(!ticks){
162
+ ticks = document.createElement('span');
163
+ ticks.className = 'ticks';
164
+ meta.appendChild(ticks);
165
+ }
166
+ ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
167
+ }
168
+
169
+ function showReactionsPop(bubble){
170
+ hideReactionsPop();
171
+ const pop = document.createElement('div');
172
+ pop.className = 'react-pop';
173
+ pop.setAttribute('role','menu');
174
+ REACTIONS.forEach(e=>{
175
+ const b=document.createElement('button');
176
+ b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
177
+ b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
178
+ pop.appendChild(b);
179
+ });
180
+ bubble.appendChild(pop);
181
+ setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
182
+ }
183
+ function hideReactionsPop(){ document.querySelectorAll('.react-pop').forEach(p=>p.remove()); }
184
+
185
+ function toggleReaction(bubble, emoji){
186
+ bubble._reactions = bubble._reactions || new Map();
187
+ const meKey = `me:${emoji}`;
188
+ if(bubble._reactions.has(meKey)) bubble._reactions.delete(meKey);
189
+ else bubble._reactions.set(meKey, 1);
190
+ renderReactions(bubble);
191
+ }
192
+ function renderReactions(bubble){
193
+ const counts = {};
194
+ (bubble._reactions||new Map()).forEach((v,k)=>{
195
+ const em = k.split(':')[1];
196
+ counts[em] = (counts[em]||0) + 1;
197
+ });
198
+ let row = bubble.querySelector('.react-row');
199
+ if(!row){
200
+ row = document.createElement('div'); row.className='react-row';
201
+ bubble.appendChild(row);
202
+ }
203
+ row.innerHTML = '';
204
+ Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
205
+ const chip = document.createElement('span');
206
+ chip.className='react-chip';
207
+ chip.innerHTML = `${em} <span class="count">${c}</span>`;
208
+ row.appendChild(chip);
209
+ });
210
+ if(Object.keys(counts).length===0) row.remove();
211
+ }
212
+
213
+ function startReply(bubble){
214
+ const textNode = bubble.querySelector('.copy')?.textContent ?? '';
215
+ replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
216
+ $('replySnippet').textContent = replyTarget.text || '(media)';
217
+ $('replyBanner').classList.remove('d-none');
218
+ bubble.classList.add('swipe-hint');
219
+ setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
220
+ }
221
+ function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
222
+ $('replyCancel').onclick = cancelReply;
223
+
224
+ // ---------- Bubble creators ----------
225
+ function armBubbleInteractions(bubble, isThem=false){
226
+ let pressTimer = null;
227
+ const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
228
+ const endPress = ()=>{ clearTimeout(pressTimer); };
229
+
230
+ bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
231
+ bubble.addEventListener('pointerdown', startPress);
232
+ bubble.addEventListener('pointerup', endPress);
233
+ bubble.addEventListener('pointerleave', endPress);
234
+
235
+ if(isThem){
236
+ let sx=0, dx=0;
237
+ bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
238
+ bubble.addEventListener('touchmove', (e)=>{
239
+ dx = e.touches[0].clientX - sx;
240
+ if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`;
241
+ }, {passive:true});
242
+ bubble.addEventListener('touchend', ()=>{
243
+ if(dx>56) startReply(bubble);
244
+ bubble.style.transform = '';
245
+ });
246
+ }
247
+ }
248
+
249
+ function bubbleMe(html) {
250
+ const row = document.createElement('div'); row.className='row-end';
251
+ row.innerHTML = `
252
+ <div class="bubble-you bubble shadow-bubble">
253
+ <div class="copy">${html}</div>
254
+ <div class="meta">${timeNow()}</div>
255
+ </div>
256
+ <img src="avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
257
+ `;
258
+ chat.appendChild(row); scrollBottom();
259
+ const b = row.querySelector('.bubble-you');
260
+ armBubbleInteractions(b, false);
261
+ setTimeout(()=>markDelivered(b, true), 450);
262
+ return b;
263
+ }
264
+
265
+ function bubbleThem(html) {
266
+ const row = document.createElement('div'); row.className='row-start';
267
+ row.innerHTML = `
268
+ <img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
269
+ <div class="bubble-them bubble shadow-bubble">
270
+ <div class="copy">${html}</div>
271
+ <div class="meta">${timeNow()}</div>
272
+ </div>
273
+ `;
274
+ chat.appendChild(row); scrollBottom();
275
+ const b = row.querySelector('.bubble-them');
276
+ armBubbleInteractions(b, true);
277
+ return b;
278
+ }
279
+
280
+ // ---------- Blast helper ----------
281
+ function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
282
+ const content = preview
283
+ ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
284
+ : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
285
+ const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
286
+ bubble.classList.add('bubble-blast');
287
+ setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
288
+ }
289
+
290
+ // ---------- Input behavior ----------
291
+ const typing = $('typing');
292
+ function setTyping(on){ typing.classList.toggle('d-none', !on); }
293
+ const input = $('input');
294
+ input.addEventListener('input', ()=>{
295
+ input.style.height='auto';
296
+ input.style.height=Math.min(input.scrollHeight, 140)+'px';
297
+ });
298
+ input.addEventListener('keydown',(e)=>{
299
+ if(e.key==='Escape'){ input.value=''; input.style.height='auto'; }
300
+ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendText(); }
301
+ });
302
+ const normalizeInputText = (t)=> t.replace(/[’‘]/g,"'").replace(/[“”]/g,'"').replace(/\s+/g,' ').trim();
303
+
304
+ // ---------- Send text ----------
305
+ async function sendText(){
306
+ let t = normalizeInputText(input.value);
307
+ if(!t) return;
308
+
309
+ if(replyTarget){
310
+ const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : '';
311
+ t = `${quoted}${t}`;
312
+ }
313
+
314
+ setTyping(true); $('btnSend').disabled=true;
315
+ try{
316
+ const fd = new FormData(); fd.append('text', t);
317
+ const j = await fetchJSON(api('check_text'), { method: 'POST', body: fd });
318
+ if (j.safe) { bubbleMe(esc(t)); cancelReply(); }
319
+ else {
320
+ blastBubble({ html: esc(t), side: 'you', preview: true, icon: '🚫' });
321
+ const reason = j.reason ? ` (${j.reason}${j.unsafe_prob!=null?` · p=${(+j.unsafe_prob).toFixed(2)}`:''})` : '';
322
+ showToast('Message blocked as unsafe' + reason);
323
+ }
324
+ }catch(e){ showToast('Error: '+e.message); }
325
+ finally{
326
+ input.value=''; input.style.height='auto';
327
+ setTyping(false); $('btnSend').disabled=false;
328
+ }
329
+ }
330
+ $('btnSend').onclick = sendText;
331
+
332
+ // ---------- Image ----------
333
+ $('btnImg').onclick = ()=> $('fileImg').click();
334
+ $('fileImg').onchange = async ()=>{ const f=$('fileImg').files[0]; if(f) await handleImage(f); $('fileImg').value=''; };
335
+
336
+ async function handleImage(file){
337
+ setTyping(true);
338
+ try{
339
+ const fd = new FormData(); fd.append('file', file);
340
+ const j = await fetchJSON(api('check_image'), { method: 'POST', body: fd });
341
+ if(j.safe){
342
+ const url = URL.createObjectURL(file);
343
+ bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`);
344
+ } else {
345
+ blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
346
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason);
347
+ }
348
+ }catch(e){ showToast('Error: '+e.message); }
349
+ finally{ setTyping(false); }
350
+ }
351
+
352
+ // Drag & Drop (image only)
353
+ ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
354
+ ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
355
+ document.addEventListener('drop', async (e)=>{
356
+ const f = e.dataTransfer?.files?.[0];
357
+ if(!f) return;
358
+ if (f.type.startsWith('image/')) await handleImage(f);
359
+ else showToast('Only images supported via drop.');
360
+ }, false);
361
+
362
+ // ---------- Voice ----------
363
+ let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
364
+ const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
365
+
366
+ const pickMime = () => {
367
+ const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg'];
368
+ for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
369
+ return '';
370
+ };
371
+ async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
372
+ function setRecUI(recording){
373
+ btnStart.disabled=recording; btnStop.disabled=!recording;
374
+ recTimer.classList.toggle('d-none',!recording);
375
+ }
376
+ const fmt = (t)=>{ const m=Math.floor(t/60), s=Math.floor(t%60); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; };
377
+
378
+ btnStart.onclick = async ()=>{
379
+ try{
380
+ await ensureMic(); chunks=[];
381
+ const mime = pickMime();
382
+ mediaRecorder = new MediaRecorder(mediaStream, mime? {mimeType:mime}:{});
383
+ mediaRecorder.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
384
+ mediaRecorder.onstop = onRecordingStop;
385
+ mediaRecorder.start(250);
386
+ startTs = Date.now();
387
+ tick = setInterval(()=> recTimer.textContent = fmt((Date.now()-startTs)/1000), 300);
388
+ setRecUI(true);
389
+ setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); }, 60000);
390
+ }catch(e){ showToast('Mic error: '+e.message); }
391
+ };
392
+ btnStop.onclick = ()=>{
393
+ if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); }
394
+ if(tick){ clearInterval(tick); tick=null; }
395
+ setRecUI(false);
396
+ };
397
+
398
+ async function onRecordingStop(){
399
+ try{
400
+ setTyping(true);
401
+ const type = mediaRecorder?.mimeType || 'audio/webm';
402
+ const blob = new Blob(chunks, { type });
403
+ const fd = new FormData(); fd.append('file', blob, 'voice');
404
+ const j = await fetchJSON(api('check_audio'), { method:'POST', body: fd });
405
+ if(j.safe){
406
+ const url = URL.createObjectURL(blob);
407
+ bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`);
408
+ }else{
409
+ blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
410
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason);
411
+ }
412
+ }catch(e){ showToast('Error: '+e.message); }
413
+ finally{ setTyping(false); }
414
+ }
415
+ </script>
416
+ </body>
417
+ </html>