<?php
/**
 * discord.php — Discord chat page for the Tivi app.
 *
 * The app's Discord button opens this page. It talks to the Python bridge
 * bot (running on the Pi) via its HTTP API, ALL from the browser (JS):
 *     GET  /login      -> OAuth2 "Log in with Discord" URL
 *     GET  /callback   -> OAuth2 exchange (Discord redirects back here)
 *     GET  /me         -> who is logged in
 *     GET  /messages   -> recent messages in the room
 *     POST /send       -> post a message
 *     GET  /logout     -> revoke the session
 *
 * IMPORTANT — why this is all client-side:
 *   The bot runs on a server behind the user's home router. The PANEL SERVER
 *   (your panel host) cannot reach it, but the TV's BROWSER can (same home
 *   network, or via the public tunnel). So the browser talks to the bot
 *   directly. The bot URL is set in config.ini under [discord] bot_url.
 *
 * TV-friendly: large text, D-pad navigation, on-screen keyboard for typing.
 */

include('includes/functions.php');

if (!headers_sent()) {
    header_remove('Cache-Control');
    header('Cache-Control: no-store, no-cache, private, must-revalidate, max-age=0', true);
    header('CDN-Cache-Control: no-store', true);
    header('Pragma: no-cache', true);
    header('Expires: 0', true);
}

// Bot URL from config.ini — the Pi's LAN address, reachable by the TV browser.
$botUrl = rtrim((string) ($config_ini['discord']['bot_url'] ?? ''), '/');
if ($botUrl === '') {
    $botUrl = 'http://127.0.0.1:8090';
}
// JS-safe version of the bot URL (heredoc interpolates variables, not PHP tags)
$botUrlJs = htmlspecialchars($botUrl, ENT_QUOTES, 'UTF-8');

$css = <<<'CSS'
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{background:linear-gradient(160deg,#0b1020 0%,#101a33 55%,#0b1020 100%);color:#fff;font-family:'Segoe UI',Roboto,Arial,sans-serif;overflow:hidden;display:flex;flex-direction:column}
.top{display:flex;align-items:center;gap:1rem;padding:1.2rem 1.5rem;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04)}
.top .logo{width:2.6rem;height:2.6rem;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0}
.top h1{font-size:clamp(1.1rem,3vh,1.6rem);font-weight:700;flex:1}
.top .who{font-size:clamp(.8rem,2vh,1rem);opacity:.8}
.msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.6rem}
.msg{max-width:85%;padding:.7rem 1rem;border-radius:1rem;font-size:clamp(.95rem,2.4vh,1.2rem);line-height:1.35}
.msg .au{font-size:.75em;font-weight:700;margin-bottom:.2rem;opacity:.85}
.msg.them{align-self:flex-start;background:rgba(255,255,255,.08);border-bottom-left-radius:.2rem}
.msg .tm{font-size:.7em;opacity:.5;margin-top:.3rem}
.msg .del{background:rgba(255,80,80,.2);color:#ff6b6b;border:1px solid rgba(255,80,80,.4);border-radius:.4rem;padding:.1rem .4rem;margin-left:.4rem;font-size:.8em;cursor:pointer;outline:none}
.msg .del:focus,.msg .del:hover{background:rgba(255,80,80,.4)}
.empty{flex:1;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.5);font-size:clamp(1rem,2.6vh,1.3rem);text-align:center;padding:2rem}
.login{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.2rem;padding:2rem;text-align:center}
.login .big{font-size:clamp(1.2rem,3.4vh,1.8rem);font-weight:700}
.login .sub{font-size:clamp(.85rem,2.2vh,1.1rem);opacity:.7;max-width:34rem}
.btn{display:inline-flex;align-items:center;gap:.7rem;padding:.9rem 1.8rem;border-radius:2rem;font-size:clamp(1rem,2.6vh,1.3rem);font-weight:700;cursor:pointer;outline:none;border:none;text-decoration:none}
.btn:focus{outline:3px solid #5865F2;outline-offset:2px}
.btn.discord{background:#5865F2;color:#fff}
.btn.discord:focus,.btn.discord:hover{background:#4752c4}
.btn.ghost{background:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.2)}
.btn.ghost:focus,.btn.ghost:hover{background:rgba(255,255,255,.15)}
.err{color:#ff6b6b;font-size:clamp(.9rem,2.2vh,1.1rem);max-width:34rem;text-align:center}
.inputbar{display:flex;align-items:center;gap:.8rem;padding:.8rem 1.2rem;border-top:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04)}
.inputbar .draft{flex:1;font-size:clamp(.95rem,2.4vh,1.2rem);padding:.7rem 1rem;border-radius:1rem;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;min-height:2.6rem;display:flex;align-items:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.kb{display:none;padding:.6rem 1rem;border-top:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05)}
.kb.show{display:block}
.kb .row{display:flex;gap:.4rem;margin-bottom:.4rem}
.kb .key{flex:1;padding:.7rem 0;text-align:center;font-size:clamp(1rem,2.6vh,1.3rem);font-weight:600;border-radius:.6rem;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);cursor:pointer;outline:none;color:#fff}
.kb .key:focus{outline:none}
.kb .key.active{background:#5865F2;border-color:#5865F2;color:#fff}
.kb .key.space{flex:4}
.kb .key.wide{flex:2}
.hint{position:fixed;bottom:.6rem;left:0;right:0;text-align:center;font-size:clamp(.7rem,1.6vh,.9rem);opacity:.4;letter-spacing:1px;pointer-events:none}
.chatwrap{display:flex;flex:1;min-height:0}
.sidebar{width:clamp(9rem,22vw,16rem);border-right:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.03);display:flex;flex-direction:column;min-height:0}
.sidebar .sh{font-size:clamp(.7rem,1.8vh,.9rem);font-weight:700;opacity:.6;padding:.8rem 1rem .4rem;letter-spacing:1px}
.sidebar .mlist{flex:1;overflow-y:auto;padding:.2rem .5rem .6rem}
.sidebar .m{display:flex;align-items:center;gap:.6rem;padding:.45rem .5rem;border-radius:.6rem;font-size:clamp(.8rem,2vh,1rem)}
.sidebar .m .av{width:1.6rem;height:1.6rem;border-radius:50%;background:#5865F2;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:.8em;overflow:hidden}
.sidebar .m .av img{width:100%;height:100%;object-fit:cover}
.sidebar .m .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sidebar .m.me{color:#5865F2;font-weight:700}
.sidebar .empty{color:rgba(255,255,255,.4);font-size:clamp(.7rem,1.7vh,.85rem);padding:.5rem 1rem;text-align:center}
CSS;

$js = <<<JS
(function(){
var BOT = "$botUrlJs";
var draft = "";
var kbOpen = false;
var myName = "";
var TOKEN = localStorage.getItem("discord_token") || "";
var firstLoad = true; // scroll to the LATEST post the first time the room opens
var userScrolledUp = false; // set when the user scrolls up to read past messages

// On every panel page load, force the APK's keyboardOpen flag OFF. The APK
// skips the oauth2 scroll/click whenever this flag is true, so if it's ever
// left stuck true the Authorize button becomes unreachable. Resetting it here
// guarantees the oauth2 page can always be scrolled.
if(window.TiviBridge){ try{ window.TiviBridge.setKeyboardOpen(false); }catch(e){} }
// Tell the APK this is the Discord chat room, so the on-screen 🎤 key works
// here (and only here).
if(window.TiviBridge && window.TiviBridge.setChatOpen){ try{ window.TiviBridge.setChatOpen(true); }catch(e){} }
// Tell the APK where the bot is, so voice can send audio to /transcribe.
if(window.TiviBridge && window.TiviBridge.setBotUrl){ try{ window.TiviBridge.setBotUrl(BOT); }catch(e){} }

// Called by the APK when the system voice recognizer returns text.
// It types the text into the chat box AND auto-posts it (hold Play/Pause,
// talk, release -> it sends).
window.TiviVoiceResult = function(text){
  if(text){
    draft = text;
    document.getElementById("draft").textContent = draft;
    sendMsg();
  }
};

function esc(s){var d=document.createElement("div");d.textContent=s==null?"":String(s);return d.innerHTML}
function fetchT(u,o){
  var to=setTimeout(function(){},10000);
  return fetch(u,o||{}).then(function(r){clearTimeout(to);return r.json().catch(function(){return {}})}).catch(function(e){clearTimeout(to);return {}});
}
function qs(name){
  var m=location.search.match(new RegExp("[?&]"+name+"=([^&]*)"));
  return m?decodeURIComponent(m[1].replace(/\+/g," ")):"";
}
function fmt(ts){if(!ts)return "";var d=new Date(ts*1000);return d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}

function showLogin(err){
  document.getElementById("login").style.display="flex";
  document.getElementById("chat").style.display="none";
  var e=document.getElementById("err"); if(e)e.textContent=err||"";
  var b=document.getElementById("loginBtn");
  if(b) b.onclick=function(){
    // Make sure the APK's keyboardOpen flag is OFF before we leave for the
    // oauth2 page. If it's stuck true, the APK skips the oauth2 scroll/click
    // and the Authorize button becomes unreachable.
    if(window.TiviBridge){ try{ window.TiviBridge.setKeyboardOpen(false); }catch(e){} }
    fetchT(BOT+"/login").then(function(j){ if(j&&j.login_url) location.href=j.login_url; });
  };
}
function showChat(name){
  myName = name || "";
  document.getElementById("login").style.display="none";
  document.getElementById("chat").style.display="flex";
  var w=document.getElementById("who"); if(w)w.textContent=name||"";
  loadMsgs();
  loadMembers();
  setInterval(loadMsgs,5000);
  setInterval(loadMembers,15000);
}
function loadMembers(){
  fetchT(BOT+"/members?token="+encodeURIComponent(TOKEN)).then(function(j){
    if(!j||j.error){ return; }
    var box=document.getElementById("mlist");
    var html="";
    (j.members||[]).forEach(function(m){
      var av=m.avatar?'<img src="'+esc(m.avatar)+'" onerror="this.style.display=\'none\'">':'';
      var meCls=(m.name===myName)?' me':'';
      html+='<div class="m'+meCls+'"><div class="av">'+av+'</div><div class="nm">'+esc(m.name)+'</div></div>';
    });
    if(!html) html='<div class="empty">No members online</div>';
    box.innerHTML=html;
  });
}
function loadMsgs(){
  fetchT(BOT+"/messages?token="+encodeURIComponent(TOKEN)).then(function(j){
    if(!j||j.error){ return; }
    var box=document.getElementById("msgs");
    var html="";
    (j.messages||[]).forEach(function(m){
      // Render [IMG]url[/IMG] markers as <img> so posted images show up.
      // referrerpolicy="no-referrer" stops Discord's CDN from blocking the
      // image when the WebView sends a cross-origin Referer header.
      // NOTE: u is ALREADY escaped by the outer esc(m.content), so do NOT
      // call esc(u) again — that double-escapes the URL (& -> &amp; -> &amp;amp;)
      // and breaks the image.
      var body=esc(m.content).replace(/\[IMG\]([^\[]*?)\[\/IMG\]/g,function(m0,u){
        return '<img src="'+u+'" referrerpolicy="no-referrer" style="max-width:100%;max-height:14rem;border-radius:.6rem;margin-top:.4rem;display:block">';
      });
      // Show a delete button only on the current user's own messages.
      var delBtn = (m.author===myName)
        ? ' <button class="del" data-id="'+esc(m.id)+'">✕</button>'
        : '';
      html+='<div class="msg them"><div class="au">'+esc(m.author)+'</div>'+body+delBtn+'<div class="tm">'+fmt(m.ts)+'</div></div>';
    });
    if(!html) html='<div class="empty">No messages yet. Say hello!</div>';
    box.innerHTML=html;
    // Wire up the delete buttons.
    box.querySelectorAll(".del").forEach(function(b){
      b.onclick=function(){
        var id=b.getAttribute("data-id");
        fetchT(BOT+"/delete?token="+encodeURIComponent(TOKEN)+"&id="+encodeURIComponent(id)).then(function(j){
          if(j&&j.ok){ loadMsgs(); }
          else { setStatus("ERR: "+(j&&j.error?j.error:"delete failed")); }
        });
      };
    });
    // Scroll so the LATEST post is visible. On the very first load (or when
    // the keyboard is open) always jump to the bottom — otherwise the room
    // opens showing the oldest post. When the keyboard is closed and the
    // user has already scrolled up to read past messages, don't yank them
    // back down (only auto-scroll if they're already near the bottom).
    if(firstLoad || kbOpen){
      box.scrollTop=box.scrollHeight;
      firstLoad=false;
    } else if(!userScrolledUp){
      var nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
      if(nearBottom){ box.scrollTop=box.scrollHeight; }
    }
    // Images load AFTER innerHTML is set, so the last post (often an image)
    // can end up below the fold. Re-scroll to the bottom once each image
    // finishes loading — but only if the user hasn't scrolled up.
    box.querySelectorAll("img").forEach(function(im){
      im.onload=function(){
        if(!userScrolledUp){ box.scrollTop=box.scrollHeight; }
      };
    });
  });
  // Track whether the user has scrolled up to read past messages. If they
  // have, stop auto-scrolling so they aren't yanked back down.
  var box=document.getElementById("msgs");
  if(box){
    box.onscroll=function(){
      var nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
      userScrolledUp = !nearBottom;
    };
  }
}
function setStatus(s){
  var el=document.getElementById("status");
  if(el) el.textContent=s||"";
}
function sendMsg(){
  var t=draft.trim(); if(!t)return;
  setStatus("Sending...");
  fetchT(BOT+"/send?token="+encodeURIComponent(TOKEN),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:t})}).then(function(j){
    if(j&&j.ok){ draft=""; document.getElementById("draft").textContent=""; setStatus(""); loadMsgs(); }
    else { setStatus("ERR: "+(j&&j.error?j.error:"no response / network")); }
  });
}
function toggleKb(){
  kbOpen=!kbOpen;
  document.getElementById("kb").classList.toggle("show",kbOpen);
  if(window.TiviBridge){ try{ window.TiviBridge.setKeyboardOpen(kbOpen); }catch(e){} }
  if(kbOpen){
    // Do NOT auto-select a key here. If we focus a key (e.g. "q"), the same
    // OK/Enter press that opened the keyboard fires again as a keydown and
    // immediately types that letter. Leave no key selected; the user picks
    // one with the D-pad first.
    var keys=document.getElementById("kb").querySelectorAll(".key");
    keys.forEach(function(k){k.classList.remove("active");});
    // Scroll the chat to the bottom so the latest post stays visible above
    // the keyboard while typing.
    var box=document.getElementById("msgs");
    if(box){ box.scrollTop=box.scrollHeight; }
  } else {
    // Closing the keyboard: jump straight to Logout so the user doesn't have
    // to scroll back up through the whole chat history to reach it.
    var lo=document.getElementById("logoutBtn");
    if(lo){ lo.focus(); }
  }
}
var kbShift=false, kbSym=false, kbEmoji=false;
function keyPress(k){
  if(k==="SPACE"){draft+=" ";}
  else if(k==="BACK"){draft=draft.slice(0,-1);}
  else if(k==="SEND"){sendMsg();return;}
  else if(k==="SHIFT"){kbShift=!kbShift;kbSym=false;kbEmoji=false;buildKb();return;}
  else if(k==="123"){kbSym=true;kbShift=false;kbEmoji=false;buildKb();return;}
  else if(k==="ABC"){kbSym=false;kbShift=false;kbEmoji=false;buildKb();return;}
  else if(k==="EMOJI"){kbEmoji=!kbEmoji;kbSym=false;kbShift=false;buildKb();return;}
  else if(k==="VOICE"){
    // On-screen 🎤 key: records from the Cube's built-in mic for ~5s, then
    // transcribes and auto-posts. Do NOT press the remote mic button (that
    // launches Alexa).
    setStatus("🎤 Speak now...");
    if(window.TiviBridge && window.TiviBridge.startVoice){ try{ window.TiviBridge.startVoice(BOT); }catch(e){ setStatus(""); } }
    setTimeout(function(){ setStatus(""); }, 6000);
    return;
  }
  else if(k==="CLOSE"){toggleKb();return;}
  else {draft+=k;}
  document.getElementById("draft").textContent=draft;
}
var kbGrid=[];
var kbRow=0, kbCol=0;
function buildKb(){
  var rows;
  if(kbEmoji){
    rows=[
      ["😀","😂","😍","😎","🤔","😢","😡","👍","👎","🙏"],
      ["❤️","🔥","🎉","👏","💯","✅","❌","⭐","🏆","⚽"],
      ["🍺","☕","🍕","🎬","📺","🎵","💬","👋","🤝","😴"],
      ["ABC","SPACE","BACK","SEND","VOICE","CLOSE"]
    ];
  } else if(kbSym){
    rows=[
      ["1","2","3","4","5","6","7","8","9","0"],
      ["@","#","$","%","&","*","(",")","-","+"],
      ["!","?",".",",",":",";","'","\"","/","_"],
      ["ABC","EMOJI","SPACE","BACK","SEND","VOICE","CLOSE"]
    ];
  } else if(kbShift){
    rows=[
      ["Q","W","E","R","T","Y","U","I","O","P"],
      ["A","S","D","F","G","H","J","K","L"],
      ["Z","X","C","V","B","N","M"],
      ["123","EMOJI","SHIFT","SPACE","BACK","SEND","VOICE","CLOSE"]
    ];
  } else {
    rows=[
      ["q","w","e","r","t","y","u","i","o","p"],
      ["a","s","d","f","g","h","j","k","l"],
      ["z","x","c","v","b","n","m"],
      ["123","EMOJI","SHIFT","SPACE","BACK","SEND","VOICE","CLOSE"]
    ];
  }
  kbGrid=rows;
  var html="";
  for(var r=0;r<rows.length;r++){
    html+='<div class="row">';
    for(var c=0;c<rows[r].length;c++){
      var k=rows[r][c];
      var cls="key";
      if(k==="SPACE")cls+=" space";
      if(k==="BACK"||k==="SEND"||k==="SHIFT"||k==="123"||k==="ABC"||k==="EMOJI"||k==="VOICE"||k==="CLOSE")cls+=" wide";
      var labels={BACK:"⌫",SPACE:"Space",SEND:"Send",SHIFT:"⇧","123":"123",ABC:"ABC",EMOJI:"😊",VOICE:"🎤",CLOSE:"✕ Close"};
      var label=labels[k]||k;
      html+='<button class="'+cls+'" data-k="'+k+'">'+label+'</button>';
    }
    html+='</div>';
  }
  document.getElementById("kb").innerHTML=html;
  document.getElementById("kb").querySelectorAll(".key").forEach(function(k){ k.onclick=function(){ keyPress(k.getAttribute("data-k")); }; });
  focusKey(kbRow, kbCol);
}
function focusKey(r,c){
  if(r<0)r=0; if(r>=kbGrid.length)r=kbGrid.length-1;
  if(c<0)c=0; if(c>=kbGrid[r].length)c=kbGrid[r].length-1;
  kbRow=r; kbCol=c;
  var keys=document.getElementById("kb").querySelectorAll(".key");
  keys.forEach(function(k){k.classList.remove("active");});
  var idx=0;
  for(var i=0;i<r;i++)idx+=kbGrid[i].length;
  idx+=c;
  // Use ONLY the .active class for the highlight. Do NOT call .focus() on the
  // key — focusing a key makes the WebView scroll the page to keep it in view,
  // which causes the chat window to jump up and down.
  if(idx<keys.length){ keys[idx].classList.add("active"); }
}
function kbMove(dir){
  var r=kbRow, c=kbCol;
  if(dir==="left"){c--;}
  else if(dir==="right"){c++;}
  else if(dir==="up"){
    if(r===0){ // top row: pressing Up exits the keyboard and goes to Logout
      toggleKb();
      var lo=document.getElementById("logoutBtn");
      if(lo){ lo.focus(); lo.style.outline="3px solid #5865F2"; }
      return;
    }
    r--;
  }
  else if(dir==="down"){r++;}
  focusKey(r,c);
}
function kbEnter(){
  // Only act if a key is actually selected (has the .active class). If the
  // keyboard was just opened and no key is selected yet, do nothing — this
  // stops the same OK/Enter press that opened the keyboard from typing "q".
  var keys=document.getElementById("kb").querySelectorAll(".key");
  var active=-1;
  for(var i=0;i<keys.length;i++){ if(keys[i].classList.contains("active")){ active=i; break; } }
  if(active<0) return;
  keyPress(keys[active].getAttribute("data-k"));
}

// --- OAuth callback: Discord redirected back with ?code= ---
var code=qs("code");
if(code){
  // Hide the login page immediately so it doesn't flash while /callback runs.
  document.getElementById("login").style.display="none";
  document.getElementById("chat").style.display="flex";
  fetchT(BOT+"/callback?code="+encodeURIComponent(code)).then(function(j){
    if(j&&j.token){
      TOKEN=j.token;
      localStorage.setItem("discord_token",TOKEN);
      history.replaceState({},document.title,location.pathname);
      showChat(j.name);
    } else {
      showLogin((j&&j.message)||(j&&j.error)||"Login failed");
    }
  });
} else {
  // --- Normal load ---
  if(TOKEN){
    // Already logged in: hide the login page immediately so it doesn't flash,
    // show the chat while /me confirms the session.
    document.getElementById("login").style.display="none";
    document.getElementById("chat").style.display="flex";
    fetchT(BOT+"/me?token="+encodeURIComponent(TOKEN)).then(function(j){
      if(j&&j.name){ showChat(j.name); }
      else { localStorage.removeItem("discord_token"); showLogin(""); }
    });
  } else {
    showLogin("");
  }
}
// Attach handlers unconditionally (so login/logout always work)
var kbBtn=document.getElementById("kbBtn"); if(kbBtn)kbBtn.onclick=toggleKb;
var loBtn=document.getElementById("logoutBtn"); if(loBtn)loBtn.onclick=function(){
  fetchT(BOT+"/logout?token="+encodeURIComponent(TOKEN));
  localStorage.removeItem("discord_token");
  location.reload();
};
buildKb();
document.addEventListener("keydown",function(e){
  var k=e.key, c=e.keyCode;
  if(kbOpen){
    if(k==="ArrowLeft"||c===37){e.preventDefault();kbMove("left");}
    else if(k==="ArrowRight"||c===39){e.preventDefault();kbMove("right");}
    else if(k==="ArrowUp"||c===38){e.preventDefault();kbMove("up");}
    else if(k==="ArrowDown"||c===40){e.preventDefault();kbMove("down");}
    else if(k==="Enter"||c===13){e.preventDefault();kbEnter();}
    else if(k==="Backspace"||c===8){e.preventDefault();keyPress("BACK");}
    else if(k==="Escape"||c===27){e.preventDefault();toggleKb();}
    return;
  }
  if(k==="Enter"||c===13){
    var a=document.activeElement;
    if(a&&a.classList&&a.classList.contains("key")){ a.click(); }
  }
  // Keyboard closed: do NOT hijack the arrow keys. Let the WebView handle
  // them natively so the D-pad can scroll the chat window and reach the
  // Logout / keyboard buttons on its own.
});
})();
JS;

echo '<!doctype html><html lang="en"><head><meta charset="utf-8">'
   . '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
   . '<title>Discord</title><style>' . $css . '</style></head><body>'
   . '<div class="top">'
   . '<div class="logo">💬</div>'
   . '<h1>Discord Room</h1>'
   . '<span class="who" id="who"></span>'
   . '</div>'
   . '<div class="login" id="login">'
   . '<div class="big">Join the Discord Room</div>'
   . '<div class="sub">Log in with your Discord account. Only members of the room can get in.</div>'
   . '<a class="btn discord" id="loginBtn" href="#">Log in with Discord</a>'
   . '<div class="err" id="err"></div>'
   . '</div>'
   . '<div class="chat" id="chat" style="display:none;flex:1;flex-direction:column;min-height:0">'
   . '<div class="chatwrap">'
   . '<div class="sidebar">'
   . '<div class="sh">MEMBERS</div>'
   . '<div class="mlist" id="mlist"></div>'
   . '</div>'
   . '<div class="msgs" id="msgs" tabindex="0"></div>'
   . '</div>'
   . '<div class="inputbar">'
   . '<div class="draft" id="draft"></div>'
   . '<button class="btn ghost" id="kbBtn" style="padding:.6rem 1rem">⌨</button>'
   . '<button class="btn ghost" id="logoutBtn" style="padding:.6rem 1rem">Logout</button>'
   . '</div>'
   . '<div id="status" style="padding:.3rem 1rem;font-size:.8rem;color:#ffb454;min-height:1.2rem"></div>'
   . '<div class="kb" id="kb"></div>'
   . '</div>'
   . '<div class="hint">D-pad to navigate · Enter to select</div>'
   . '<script>' . $js . '</script></body></html>';
