BubbleGuard / index.html
MetiMiester's picture
Update index.html
37a2937 verified
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>BubbleGuard – Safe Chat</title>
<link rel="icon" href="logo.png">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="styles.css" rel="stylesheet">
</head>
<body>
<div class="app">
<header class="glass py-2 px-3">
<div class="container-fluid d-flex align-items-center gap-3">
<button class="btn btn-ico" type="button" aria-label="Back" title="Back"></button>
<div class="d-flex align-items-center gap-2">
<img src="logo.png" alt="" class="rounded-3 header-logo" onerror="this.style.display='none'">
<div class="d-flex flex-column lh-1">
<div class="app-title">BubbleGuard</div>
<div id="health" class="subtle" aria-live="polite">Checking…</div>
</div>
</div>
<div class="ms-auto d-flex align-items-center gap-1">
<button id="theme" class="btn btn-ico" type="button" aria-label="Toggle theme" title="Appearance">🌓</button>
</div>
</div>
</header>
<main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
<div class="row-start">
<img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
<div class="bubble-them bubble shadow-bubble">
<div class="copy">Hey there 💖 Welcome to BubbleGuard’s safe chat! Share pics, voice notes, or messages — we’ll keep it kind.</div>
<div class="meta">now</div>
</div>
</div>
</main>
<footer class="composer-wrap">
<div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
<div class="rb-body">
<div class="rb-line">
<span class="rb-label">Replying to</span>
<span id="replySnippet" class="rb-snippet"></span>
</div>
<button id="replyCancel" class="btn-ico rb-close" aria-label="Cancel reply"></button>
</div>
</div>
<div class="container composer">
<input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
<button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image"></button>
<div class="input-shell">
<textarea id="input" rows="1" placeholder="Write a message here…" class="form-control input-ios" aria-label="Message input"></textarea>
<div id="typing" class="typing d-none" aria-hidden="true">
<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
</div>
</div>
<div class="audio-controls d-flex align-items-center gap-1">
<button id="btnStart" class="btn btn-ico" title="Record" aria-label="Start recording">🎤</button>
<button id="btnStop" class="btn btn-ico" title="Stop" aria-label="Stop recording" disabled></button>
<span id="recTimer" class="pill subtle d-none" aria-live="polite">00:00</span>
</div>
<button id="btnSend" class="btn send-ios" aria-label="Send"></button>
</div>
<div class="toast-zone">
<div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-body" id="toastBody">Hello</div>
</div>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// ---- Hard-wire your Space origin (change if you rename it) ----
const SPACE_ORIGIN = "https://metimiester-bubbleguard.hf.space"; // <<— EDIT if org/space changes
const api = (p) => `${SPACE_ORIGIN}/${String(p).replace(/^\/+/, '')}`;
async function fetchJSON(url, opts) {
const res = await fetch(url, Object.assign({ headers: { 'Accept': 'application/json' } }, opts || {}));
const ct = (res.headers.get('content-type') || '').toLowerCase();
const text = await res.text();
if (!ct.includes('application/json')) throw new Error('Non-JSON: ' + text.slice(0,160));
let body; try { body = JSON.parse(text); } catch { throw new Error('Invalid JSON: ' + text.slice(0,160)); }
if (!res.ok) throw new Error(body.detail || res.status + ' ' + res.statusText);
return body;
}
// ---- Theme ----
const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
const saved = localStorage.getItem('bg-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(saved || (prefersDark ? 'dark' : 'light'));
document.getElementById('theme').onclick = () => {
const cur = document.documentElement.getAttribute('data-bs-theme');
const next = cur === 'dark' ? 'light' : 'dark';
setTheme(next); localStorage.setItem('bg-theme', next);
};
// ---- Health ----
(async () => {
const el = document.getElementById('health');
try {
const j = await fetchJSON(api('api/health'));
const t = j.text_thresholds || {};
el.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
} catch (e) {
console.warn('health error:', e);
el.textContent = 'Offline – ' + (e.message || e);
}
})();
// ---- Helpers ----
const $ = (id)=>document.getElementById(id);
const chat = $('chat'), input=$('input'), typing=$('typing');
const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
const toastEl = $('toast'), toastBody = $('toastBody');
const toast = new bootstrap.Toast(toastEl, { delay: 4200 });
const timeNow = () => new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const scrollBottom = () => { chat.scrollTop = chat.scrollHeight; };
const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// ---- Reactions/Reply (same as before) ----
const REACTIONS = ["👍","❤️","😂","😮","😢"];
let replyTarget = null;
function markDelivered(bubble, double=false){
const meta = bubble.querySelector('.meta');
if(!meta) return;
let ticks = meta.querySelector('.ticks');
if(!ticks){ ticks = document.createElement('span'); ticks.className = 'ticks'; meta.appendChild(ticks); }
ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
}
function showReactionsPop(bubble){
hideReactionsPop();
const pop = document.createElement('div');
pop.className = 'react-pop'; pop.setAttribute('role','menu');
REACTIONS.forEach(e=>{
const b=document.createElement('button'); b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
pop.appendChild(b);
});
bubble.appendChild(pop);
setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
}
function hideReactionsPop(){ document.querySelectorAll('.react-pop').forEach(p=>p.remove()); }
function toggleReaction(bubble, emoji){
bubble._reactions = bubble._reactions || new Map();
const meKey = `me:${emoji}`;
if(bubble._reactions.has(meKey)) bubble._reactions.delete(meKey);
else bubble._reactions.set(meKey, 1);
renderReactions(bubble);
}
function renderReactions(bubble){
const counts = {};
(bubble._reactions||new Map()).forEach((v,k)=>{ const em = k.split(':')[1]; counts[em] = (counts[em]||0) + 1; });
let row = bubble.querySelector('.react-row');
if(!row){ row = document.createElement('div'); row.className='react-row'; bubble.appendChild(row); }
row.innerHTML = '';
Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
const chip = document.createElement('span'); chip.className='react-chip'; chip.innerHTML = `${em} <span class="count">${c}</span>`;
row.appendChild(chip);
});
if(Object.keys(counts).length===0) row.remove();
}
function startReply(bubble){
const textNode = bubble.querySelector('.copy')?.textContent ?? '';
replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
$('replySnippet').textContent = replyTarget.text || '(media)';
$('replyBanner').classList.remove('d-none');
bubble.classList.add('swipe-hint'); setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
}
function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
$('replyCancel').onclick = cancelReply;
function armBubbleInteractions(bubble, isThem=false){
let pressTimer = null;
const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
const endPress = ()=>{ clearTimeout(pressTimer); };
bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
bubble.addEventListener('pointerdown', startPress);
bubble.addEventListener('pointerup', endPress);
bubble.addEventListener('pointerleave', endPress);
if(isThem){
let sx=0, dx=0;
bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
bubble.addEventListener('touchmove', (e)=>{ dx = e.touches[0].clientX - sx; if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`; }, {passive:true});
bubble.addEventListener('touchend', ()=>{ if(dx>56) startReply(bubble); bubble.style.transform = ''; });
}
}
function bubbleMe(html) {
const row = document.createElement('div'); row.className='row-end';
row.innerHTML = `
<div class="bubble-you bubble shadow-bubble">
<div class="copy">${html}</div>
<div class="meta">${timeNow()}</div>
</div>
<img src="avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
`;
chat.appendChild(row); scrollBottom();
const b = row.querySelector('.bubble-you'); armBubbleInteractions(b, false); setTimeout(()=>markDelivered(b, true), 450); return b;
}
function bubbleThem(html) {
const row = document.createElement('div'); row.className='row-start';
row.innerHTML = `
<img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
<div class="bubble-them bubble shadow-bubble">
<div class="copy">${html}</div>
<div class="meta">${timeNow()}</div>
</div>
`;
chat.appendChild(row); scrollBottom();
const b = row.querySelector('.bubble-them'); armBubbleInteractions(b, true); return b;
}
function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
const content = preview ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
: `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
bubble.classList.add('bubble-blast'); setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
}
function setTyping(on){ typing.classList.toggle('d-none', !on); }
input.addEventListener('input', ()=>{ input.style.height='auto'; input.style.height=Math.min(input.scrollHeight, 140)+'px'; });
input.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ input.value=''; input.style.height='auto'; } if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendText(); } });
const normalizeInputText = (t)=> t.replace(/[’‘]/g,"'").replace(/[“”]/g,'"').replace(/\s+/g,' ').trim();
async function sendText(){
let t = normalizeInputText(input.value); if(!t) return;
if(replyTarget){ const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : ''; t = `${quoted}${t}`; }
setTyping(true); btnSend.disabled=true;
try{
const fd = new FormData(); fd.append('text', t);
const j = await fetchJSON(api('api/check_text'), { method: 'POST', body: fd });
if (j.safe) { bubbleMe(esc(t)); cancelReply(); }
else {
blastBubble({ html: esc(t), side: 'you', preview: true, icon: '🚫' });
const reason = j.reason ? ` (${j.reason}${j.unsafe_prob!=null?` · p=${(+j.unsafe_prob).toFixed(2)}`:''})` : '';
showToast('Message blocked as unsafe' + reason);
}
}catch(e){ showToast('Error: '+e.message); }
finally{ input.value=''; input.style.height='auto'; setTyping(false); btnSend.disabled=false; }
}
const btnSend = document.getElementById('btnSend'); btnSend.onclick = sendText;
// ---- Image ----
const btnImg = document.getElementById('btnImg'); const fileImg = document.getElementById('fileImg');
btnImg.onclick = ()=> fileImg.click();
fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
async function handleImage(file){
setTyping(true);
try{
const fd = new FormData(); fd.append('file', file);
const j = await fetchJSON(api('api/check_image'), { method: 'POST', body: fd });
if(j.safe){ const url = URL.createObjectURL(file); bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`); }
else { blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason); }
}catch(e){ showToast('Error: '+e.message); }
finally{ setTyping(false); }
}
// Drag & Drop (image only)
['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
document.addEventListener('drop', async (e)=>{ const f=e.dataTransfer?.files?.[0]; if(!f) return; if(f.type.startsWith('image/')) await handleImage(f); else showToast('Only images supported via drop.'); }, false);
// ---- Voice ----
let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
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')}`; };
const pickMime = () => { const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg']; for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; return ''; };
async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
function setRecUI(r){ btnStart.disabled=r; btnStop.disabled=!r; recTimer.classList.toggle('d-none',!r); }
const btnStart=document.getElementById('btnStart'); const btnStop=document.getElementById('btnStop'); const recTimer=document.getElementById('recTimer');
btnStart.onclick = async ()=>{ try{ await ensureMic(); chunks=[]; const mime=pickMime(); mediaRecorder=new MediaRecorder(mediaStream, mime?{mimeType:mime}:{}); mediaRecorder.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); }; mediaRecorder.onstop=onRecordingStop; mediaRecorder.start(250); startTs=Date.now(); tick=setInterval(()=> recTimer.textContent=fmt((Date.now()-startTs)/1000),300); setRecUI(true); setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); },60000); }catch(e){ showToast('Mic error: '+e.message); } };
btnStop.onclick = ()=>{ if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); } if(tick){ clearInterval(tick); tick=null; } setRecUI(false); };
async function onRecordingStop(){
try{
setTyping(true);
const type=mediaRecorder?.mimeType||'audio/webm'; const blob=new Blob(chunks,{type}); const fd=new FormData(); fd.append('file', blob, 'voice');
const j = await fetchJSON(api('api/check_audio'), { method:'POST', body: fd });
if(j.safe){ const url=URL.createObjectURL(blob); bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`); }
else { blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason); }
}catch(e){ showToast('Error: '+e.message); }
finally{ setTyping(false); }
}
</script>
</body>
</html>