Spaces:
Running
Running
Update index.html
Browse files- index.html +19 -40
index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
| 6 |
<title>BubbleGuard – Safe Chat</title>
|
| 7 |
|
| 8 |
-
<!--
|
| 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">
|
|
@@ -32,7 +32,6 @@
|
|
| 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">
|
|
@@ -44,8 +43,6 @@
|
|
| 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">
|
|
@@ -58,7 +55,6 @@
|
|
| 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">
|
|
@@ -77,7 +73,6 @@
|
|
| 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>
|
|
@@ -86,30 +81,20 @@
|
|
| 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 |
-
// ----------
|
| 95 |
-
const api = (p) => new URL(p, window.location.href).toString();
|
| 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 |
-
|
| 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 |
|
|
@@ -126,14 +111,14 @@
|
|
| 126 |
|
| 127 |
// ---------- Health ----------
|
| 128 |
(async () => {
|
|
|
|
| 129 |
try {
|
| 130 |
const j = await fetchJSON(api('health'));
|
| 131 |
const t = j.text_thresholds || {};
|
| 132 |
-
|
| 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 |
|
|
@@ -150,7 +135,7 @@
|
|
| 150 |
const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
|
| 151 |
const esc = (s)=>s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 152 |
|
| 153 |
-
// ---------- Reactions & Reply
|
| 154 |
const REACTIONS = ["👍","❤️","😂","😮","😢"];
|
| 155 |
let replyTarget = null;
|
| 156 |
|
|
@@ -221,7 +206,6 @@
|
|
| 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); };
|
|
@@ -277,7 +261,6 @@
|
|
| 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>`
|
|
@@ -287,10 +270,7 @@
|
|
| 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';
|
|
@@ -299,9 +279,9 @@
|
|
| 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;
|
|
@@ -311,7 +291,7 @@
|
|
| 311 |
t = `${quoted}${t}`;
|
| 312 |
}
|
| 313 |
|
| 314 |
-
setTyping(true);
|
| 315 |
try{
|
| 316 |
const fd = new FormData(); fd.append('text', t);
|
| 317 |
const j = await fetchJSON(api('check_text'), { method: 'POST', body: fd });
|
|
@@ -324,14 +304,14 @@
|
|
| 324 |
}catch(e){ showToast('Error: '+e.message); }
|
| 325 |
finally{
|
| 326 |
input.value=''; input.style.height='auto';
|
| 327 |
-
setTyping(false);
|
| 328 |
}
|
| 329 |
}
|
| 330 |
-
|
| 331 |
|
| 332 |
-
//
|
| 333 |
-
|
| 334 |
-
|
| 335 |
|
| 336 |
async function handleImage(file){
|
| 337 |
setTyping(true);
|
|
@@ -359,9 +339,9 @@
|
|
| 359 |
else showToast('Only images supported via drop.');
|
| 360 |
}, false);
|
| 361 |
|
| 362 |
-
//
|
| 363 |
let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
|
| 364 |
-
const
|
| 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'];
|
|
@@ -373,7 +353,6 @@
|
|
| 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{
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
| 6 |
<title>BubbleGuard – Safe Chat</title>
|
| 7 |
|
| 8 |
+
<!-- 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">
|
|
|
|
| 32 |
|
| 33 |
<!-- Chat -->
|
| 34 |
<main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
|
|
|
|
| 35 |
<div class="row-start">
|
| 36 |
<img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
|
| 37 |
<div class="bubble-them bubble shadow-bubble">
|
|
|
|
| 43 |
|
| 44 |
<!-- Composer -->
|
| 45 |
<footer class="composer-wrap">
|
|
|
|
|
|
|
| 46 |
<div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
|
| 47 |
<div class="rb-body">
|
| 48 |
<div class="rb-line">
|
|
|
|
| 55 |
|
| 56 |
<div class="container composer">
|
| 57 |
<input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
|
|
|
|
| 58 |
<button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image">+</button>
|
| 59 |
|
| 60 |
<div class="input-shell">
|
|
|
|
| 73 |
<button id="btnSend" class="btn send-ios" aria-label="Send">↑</button>
|
| 74 |
</div>
|
| 75 |
|
|
|
|
| 76 |
<div class="toast-zone">
|
| 77 |
<div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
|
| 78 |
<div class="toast-body" id="toastBody">Hello</div>
|
|
|
|
| 81 |
</footer>
|
| 82 |
</div>
|
| 83 |
|
|
|
|
| 84 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 85 |
|
|
|
|
| 86 |
<script>
|
| 87 |
+
// ---------- API helper (always relative) ----------
|
| 88 |
+
const api = (p) => new URL(String(p).replace(/^\/+/, ''), window.location.href).toString();
|
| 89 |
|
| 90 |
// ---------- JSON guard ----------
|
| 91 |
async function fetchJSON(url, opts) {
|
| 92 |
const res = await fetch(url, Object.assign({ headers: { 'Accept': 'application/json' } }, opts || {}));
|
| 93 |
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
| 94 |
const text = await res.text();
|
| 95 |
+
if (!ct.includes('application/json')) throw new Error('Non-JSON: ' + text.slice(0, 160));
|
| 96 |
+
let body; try { body = JSON.parse(text); } catch { throw new Error('Invalid JSON: ' + text.slice(0,160)); }
|
| 97 |
+
if (!res.ok) throw new Error(body.detail || res.status + ' ' + res.statusText);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
return body;
|
| 99 |
}
|
| 100 |
|
|
|
|
| 111 |
|
| 112 |
// ---------- Health ----------
|
| 113 |
(async () => {
|
| 114 |
+
const el = document.getElementById('health');
|
| 115 |
try {
|
| 116 |
const j = await fetchJSON(api('health'));
|
| 117 |
const t = j.text_thresholds || {};
|
| 118 |
+
el.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
|
|
|
|
| 119 |
} catch (e) {
|
|
|
|
| 120 |
console.warn('health error:', e);
|
| 121 |
+
el.textContent = 'Offline';
|
| 122 |
}
|
| 123 |
})();
|
| 124 |
|
|
|
|
| 135 |
const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
|
| 136 |
const esc = (s)=>s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 137 |
|
| 138 |
+
// ---------- Reactions & Reply ----------
|
| 139 |
const REACTIONS = ["👍","❤️","😂","😮","😢"];
|
| 140 |
let replyTarget = null;
|
| 141 |
|
|
|
|
| 206 |
function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
|
| 207 |
$('replyCancel').onclick = cancelReply;
|
| 208 |
|
|
|
|
| 209 |
function armBubbleInteractions(bubble, isThem=false){
|
| 210 |
let pressTimer = null;
|
| 211 |
const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
|
|
|
|
| 261 |
return b;
|
| 262 |
}
|
| 263 |
|
|
|
|
| 264 |
function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
|
| 265 |
const content = preview
|
| 266 |
? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
|
|
|
|
| 270 |
setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
|
| 271 |
}
|
| 272 |
|
|
|
|
|
|
|
| 273 |
function setTyping(on){ typing.classList.toggle('d-none', !on); }
|
|
|
|
| 274 |
input.addEventListener('input', ()=>{
|
| 275 |
input.style.height='auto';
|
| 276 |
input.style.height=Math.min(input.scrollHeight, 140)+'px';
|
|
|
|
| 279 |
if(e.key==='Escape'){ input.value=''; input.style.height='auto'; }
|
| 280 |
if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendText(); }
|
| 281 |
});
|
| 282 |
+
|
| 283 |
const normalizeInputText = (t)=> t.replace(/[’‘]/g,"'").replace(/[“”]/g,'"').replace(/\s+/g,' ').trim();
|
| 284 |
|
|
|
|
| 285 |
async function sendText(){
|
| 286 |
let t = normalizeInputText(input.value);
|
| 287 |
if(!t) return;
|
|
|
|
| 291 |
t = `${quoted}${t}`;
|
| 292 |
}
|
| 293 |
|
| 294 |
+
setTyping(true); btnSend.disabled=true;
|
| 295 |
try{
|
| 296 |
const fd = new FormData(); fd.append('text', t);
|
| 297 |
const j = await fetchJSON(api('check_text'), { method: 'POST', body: fd });
|
|
|
|
| 304 |
}catch(e){ showToast('Error: '+e.message); }
|
| 305 |
finally{
|
| 306 |
input.value=''; input.style.height='auto';
|
| 307 |
+
setTyping(false); btnSend.disabled=false;
|
| 308 |
}
|
| 309 |
}
|
| 310 |
+
btnSend.onclick = sendText;
|
| 311 |
|
| 312 |
+
// Image
|
| 313 |
+
btnImg.onclick = ()=> fileImg.click();
|
| 314 |
+
fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
|
| 315 |
|
| 316 |
async function handleImage(file){
|
| 317 |
setTyping(true);
|
|
|
|
| 339 |
else showToast('Only images supported via drop.');
|
| 340 |
}, false);
|
| 341 |
|
| 342 |
+
// Voice
|
| 343 |
let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
|
| 344 |
+
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')}`; };
|
| 345 |
|
| 346 |
const pickMime = () => {
|
| 347 |
const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg'];
|
|
|
|
| 353 |
btnStart.disabled=recording; btnStop.disabled=!recording;
|
| 354 |
recTimer.classList.toggle('d-none',!recording);
|
| 355 |
}
|
|
|
|
| 356 |
|
| 357 |
btnStart.onclick = async ()=>{
|
| 358 |
try{
|