Commit 8a98c180 by Michael Pastushkov

fix

parent 16bfe12c
...@@ -7,52 +7,35 @@ ...@@ -7,52 +7,35 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<style> <style>
:root{ :root{
/* tune these if footer/composer sizes change */
--footer-h: 2.75rem; /* fixed footer height (approx) */ --footer-h: 2.75rem; /* fixed footer height (approx) */
--composer-h: 6.5rem; /* composer block height (approx) */ --composer-h: 6.5rem; /* composer block height (approx) */
--bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem); --bottom-pad: calc(var(--footer-h) + var(--composer-h) + 1rem);
} }
body{ body{ min-height:100vh; display:flex; flex-direction:column; background:#f8f9fa; margin:0; }
min-height:100vh;
display:flex;
flex-direction:column;
background-color:#f8f9fa;
margin:0;
}
header{ position:sticky; top:0; z-index:1020; } header{ position:sticky; top:0; z-index:1020; }
main{ main{
flex:1 1 auto; flex:1 1 auto;
overflow-y:auto; overflow-y:auto; /* <-- the scrollable area */
position:relative; position:relative;
padding-bottom:var(--bottom-pad); /* leave room for composer + fixed footer */ padding-bottom:var(--bottom-pad); /* room for composer + fixed footer */
display:flex; display:flex;
flex-direction:column; flex-direction:column;
} }
.composer{ .composer{
position:sticky; position:sticky;
bottom:var(--footer-h); /* sit just above the fixed footer */ bottom:var(--footer-h);
z-index:1020; z-index:1020;
background:#fff; background:#fff;
border-top:1px solid #dee2e6; border-top:1px solid #dee2e6;
} }
footer{ footer{
position:fixed; position:fixed; bottom:0; left:0; width:100%; z-index:1020;
bottom:0; font-size:.75rem; color:#6c757d; text-align:center; padding:.5rem;
left:0; border-top:1px solid #dee2e6; background:#fdfdfd; height:var(--footer-h);
width:100%;
z-index:1020;
font-size:.75rem;
color:#6c757d;
text-align:center;
padding:.5rem;
border-top:1px solid #dee2e6;
background:#fdfdfd;
height:var(--footer-h);
box-sizing:border-box; box-sizing:border-box;
} }
...@@ -63,8 +46,8 @@ ...@@ -63,8 +46,8 @@
max-width:900px; margin:0 auto 1rem auto; padding:1rem 1.25rem; max-width:900px; margin:0 auto 1rem auto; padding:1rem 1.25rem;
border-radius:.75rem; line-height:1.6; font-size:1rem; word-break:break-word; border-radius:.75rem; line-height:1.6; font-size:1rem; word-break:break-word;
} }
.user-message{ background-color:#fff; border:1px solid #dee2e6; white-space:pre-wrap; } .user-message{ background:#fff; border:1px solid #dee2e6; white-space:pre-wrap; }
.assistant-message{ background-color:#f8f9fa; border:1px solid #dee2e6; } .assistant-message{ background:#f8f9fa; border:1px solid #dee2e6; }
.assistant-message h1,.assistant-message h2,.assistant-message h3{ margin-top:1rem; margin-bottom:.6rem; } .assistant-message h1,.assistant-message h2,.assistant-message h3{ margin-top:1rem; margin-bottom:.6rem; }
.assistant-message p{ margin:.5rem 0; } .assistant-message p{ margin:.5rem 0; }
.assistant-message code{ background:#fff; border:1px solid #e9ecef; border-radius:.25rem; padding:.05rem .35rem; } .assistant-message code{ background:#fff; border:1px solid #e9ecef; border-radius:.25rem; padding:.05rem .35rem; }
...@@ -72,7 +55,6 @@ ...@@ -72,7 +55,6 @@
.assistant-message ul,.assistant-message ol{ padding-left:1.25rem; } .assistant-message ul,.assistant-message ol{ padding-left:1.25rem; }
.assistant-message table{ width:100%; margin:1rem 0; } .assistant-message table{ width:100%; margin:1rem 0; }
/* First-run logo layout */
#mainScroll{ display:flex; flex-direction:column; position:relative; } #mainScroll{ display:flex; flex-direction:column; position:relative; }
#welcomeLogo{ flex:1 1 auto; display:flex; align-items:center; justify-content:center; user-select:none; } #welcomeLogo{ flex:1 1 auto; display:flex; align-items:center; justify-content:center; user-select:none; }
#welcomeLogo img{ #welcomeLogo img{
...@@ -80,13 +62,13 @@ ...@@ -80,13 +62,13 @@
filter:drop-shadow(0 6px 16px rgba(0,0,0,.15)); opacity:.98; filter:drop-shadow(0 6px 16px rgba(0,0,0,.15)); opacity:.98;
} }
/* ALWAYS-VISIBLE SCROLL BUTTON (fixed, above composer+footer) */ /* Scroll button: FIXED, toggled by .show */
#scrollDownBtn{ #scrollDownBtn{
position:fixed; position:fixed;
right:1rem; right:1rem;
bottom:calc(var(--footer-h) + var(--composer-h) + 1rem); /* sit above composer + footer */ bottom:calc(var(--footer-h) + var(--composer-h) + 1rem);
z-index:1100; /* above header/composer/footer */ z-index:1100;
display:inline-flex; display:none; /* hidden by default */
align-items:center; align-items:center;
justify-content:center; justify-content:center;
border:none; border:none;
...@@ -100,12 +82,12 @@ ...@@ -100,12 +82,12 @@
transition:opacity .18s ease-in-out, transform .12s ease-in-out; transition:opacity .18s ease-in-out, transform .12s ease-in-out;
opacity:.96; opacity:.96;
} }
#scrollDownBtn.show{ display:inline-flex; }
#scrollDownBtn:hover{ opacity:1; transform:translateY(-1px); } #scrollDownBtn:hover{ opacity:1; transform:translateY(-1px); }
#scrollDownBtn svg{ width:22px; height:22px; pointer-events:none; } #scrollDownBtn svg{ width:22px; height:22px; pointer-events:none; }
</style> </style>
</head> </head>
<body> <body>
<!-- Header -->
<header class="py-3 bg-dark text-white shadow-sm"> <header class="py-3 bg-dark text-white shadow-sm">
<div class="container d-flex justify-content-between align-items-center"> <div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
...@@ -115,7 +97,6 @@ ...@@ -115,7 +97,6 @@
</div> </div>
</header> </header>
<!-- Conversation (scrollable) -->
<main class="container my-3" id="mainScroll"> <main class="container my-3" id="mainScroll">
<div id="welcomeLogo"> <div id="welcomeLogo">
<img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat"> <img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat">
...@@ -123,15 +104,13 @@ ...@@ -123,15 +104,13 @@
<div id="thread"></div> <div id="thread"></div>
</main> </main>
<!-- ALWAYS VISIBLE SCROLL BUTTON --> <!-- visibility toggled by JS -->
<button id="scrollDownBtn" type="button" aria-label="Scroll down" title="Scroll down"> <button id="scrollDownBtn" type="button" aria-label="Scroll down" title="Scroll down">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<!-- down chevron -->
<path d="M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z"/> <path d="M12 16a1 1 0 0 1-.7-.29l-6-6a1 1 0 1 1 1.4-1.42L12 13.59l5.3-5.3a1 1 0 0 1 1.4 1.42l-6 6A1 1 0 0 1 12 16z"/>
</svg> </svg>
</button> </button>
<!-- Composer (sticks above fixed footer) -->
<div class="composer py-3"> <div class="composer py-3">
<div class="container"> <div class="container">
<form id="promptForm" class="d-flex flex-column gap-2"> <form id="promptForm" class="d-flex flex-column gap-2">
...@@ -144,180 +123,148 @@ ...@@ -144,180 +123,148 @@
</div> </div>
</div> </div>
<!-- Fixed, non-scrollable footer -->
<footer id="disclaimer"></footer> <footer id="disclaimer"></footer>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script> <script>
// --- i18n setup (unchanged) --- // --- i18n (unchanged) ---
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE='en'; const SUPPORTED=['en','ru'];
const SUPPORTED = ['en', 'ru']; let I18N={}; let CURRENT_LOCALE=detectLocale(); let isRunning=false;
let I18N = {};
let CURRENT_LOCALE = detectLocale(); function getLangFromQuery(){ const m=location.search.match(/[?&]lang=(en|ru)\b/i); return m?m[1].toLowerCase():null; }
let isRunning = false; function detectLocale(){ const q=getLangFromQuery(); if(q&&SUPPORTED.includes(q)) return q; const nav=(navigator.language||'').toLowerCase(); return nav.startsWith('ru')?'ru':DEFAULT_LOCALE; }
async function loadLocale(locale){ try{ const res=await fetch(`/locales/${locale}.json`,{cache:'no-store'}); if(!res.ok) throw new Error('HTTP '+res.status); I18N=await res.json(); CURRENT_LOCALE=locale; document.documentElement.lang=locale; applyI18n(); }catch(e){ if(locale!==DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE); } }
function getLangFromQuery(){ function t(key,fallback=''){ const parts=key.split('.'); let cur=I18N; for(const p of parts){ if(cur&&Object.prototype.hasOwnProperty.call(cur,p)) cur=cur[p]; else return fallback||key; } return (typeof cur==='string')?cur:(fallback||key); }
const m = location.search.match(/[?&]lang=(en|ru)\b/i); function setWelcomeImage(){ const img=document.getElementById('welcomeImg'); if(!img) return; const src={en:'/static/images/ask-a-cat-en.png', ru:'/static/images/ask-a-cat-ru.png'}; img.src=src[CURRENT_LOCALE]||src.en; }
return m ? m[1].toLowerCase() : null;
}
function detectLocale(){
const q = getLangFromQuery();
if (q && SUPPORTED.includes(q)) return q;
const nav = (navigator.language || '').toLowerCase();
if (nav.startsWith('ru')) return 'ru';
return DEFAULT_LOCALE;
}
async function loadLocale(locale){
try{
const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
I18N = await res.json();
CURRENT_LOCALE = locale;
document.documentElement.lang = locale;
applyI18n();
}catch(e){
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE);
}
}
function t(key, fallback=''){
const parts = key.split('.');
let cur = I18N;
for (const p of parts){
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return fallback || key;
}
return (typeof cur === 'string') ? cur : (fallback || key);
}
function setWelcomeImage(){
const welcomeImg = document.getElementById('welcomeImg');
if (!welcomeImg) return;
const srcByLocale = {
en: '/static/images/ask-a-cat-en.png',
ru: '/static/images/ask-a-cat-ru.png'
};
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en;
}
function applyI18n(){ function applyI18n(){
document.title = t('app.title', 'KotGPT'); document.title=t('app.title','KotGPT');
const catIcon = document.getElementById('catIcon'); const catIcon=document.getElementById('catIcon'); if(catIcon) catIcon.alt=t('header.logoAlt','Cat logo');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo'); const welcomeImg=document.getElementById('welcomeImg'); if(welcomeImg) welcomeImg.alt=t('main.welcomeAlt','Ask a Cat');
const welcomeImg = document.getElementById('welcomeImg'); const input=document.getElementById('inputText'); if(input) input.placeholder=isRunning?t('status.thinking','Thinking…'):t('composer.placeholder','What can I help you with?');
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat'); const goBtn=document.getElementById('goBtn'); if(goBtn) goBtn.textContent=t('buttons.go','Go');
const inputText = document.getElementById('inputText'); const stopBtn=document.getElementById('stopBtn'); if(stopBtn) stopBtn.textContent=t('buttons.stop','Stop');
if (inputText){ const disclaimer=document.getElementById('disclaimer'); if(disclaimer) disclaimer.textContent=t('footer.disclaimer','Beware: Cat can make mistakes.');
inputText.placeholder = isRunning const scrollBtn=document.getElementById('scrollDownBtn'); if(scrollBtn){ scrollBtn.setAttribute('aria-label', t('buttons.scrollDown','Scroll down')); scrollBtn.title=t('buttons.scrollDown','Scroll down'); }
? t('status.thinking', 'Thinking…')
: t('composer.placeholder', 'What can I help you with?');
}
const goBtn = document.getElementById('goBtn');
if (goBtn) goBtn.textContent = t('buttons.go', 'Go');
const stopBtn = document.getElementById('stopBtn');
if (stopBtn) stopBtn.textContent = t('buttons.stop', 'Stop');
const disclaimer = document.getElementById('disclaimer');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer', 'Beware: Cat can make mistakes.');
const scrollDownBtn = document.getElementById('scrollDownBtn');
if (scrollDownBtn){
scrollDownBtn.setAttribute('aria-label', t('buttons.scrollDown', 'Scroll down'));
scrollDownBtn.title = t('buttons.scrollDown', 'Scroll down');
}
setWelcomeImage(); setWelcomeImage();
} }
// --- UI refs --- // --- refs ---
const ENDPOINT = '/api/stream'; const ENDPOINT='/api/stream'; const STOPPOINT='/api/stop'; const CAT_GIF='/static/images/cat-progress.gif';
const STOPPOINT = '/api/stop'; const thread=document.getElementById('thread');
const CAT_GIF = '/static/images/cat-progress.gif'; const promptForm=document.getElementById('promptForm');
const thread = document.getElementById('thread'); const inputText=document.getElementById('inputText');
const promptForm = document.getElementById('promptForm'); const goBtn=document.getElementById('goBtn');
const inputText = document.getElementById('inputText'); const stopBtn=document.getElementById('stopBtn');
const goBtn = document.getElementById('goBtn'); const welcomeLogo=document.getElementById('welcomeLogo');
const stopBtn = document.getElementById('stopBtn'); const mainScroll=document.getElementById('mainScroll'); // scrollable container
const welcomeLogo = document.getElementById('welcomeLogo'); const scrollDownBtn=document.getElementById('scrollDownBtn');
const mainScroll = document.getElementById('mainScroll');
const scrollDownBtn = document.getElementById('scrollDownBtn'); let firstRequestDone=false;
let controller=null;
let firstRequestDone = false;
let controller = null; // --- render helpers ---
function renderMarkdown(targetEl, md){
// --- helpers --- const html=marked.parse(md,{breaks:true,gfm:true,headerIds:false});
function renderMarkdown(targetEl, markdownText){ targetEl.innerHTML=DOMPurify.sanitize(html,{USE_PROFILES:{html:true}});
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES:{ html:true } });
} }
function showThinkingCat(targetEl){ function showThinkingCat(targetEl){
targetEl.innerHTML = ''; targetEl.innerHTML='';
const img = document.createElement('img'); const img=document.createElement('img');
img.className = 'thinking-cat'; img.className='thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now(); img.src=CAT_GIF+'?t='+Date.now();
img.alt = t('status.thinking', 'Thinking…'); img.alt=t('status.thinking','Thinking…');
targetEl.appendChild(img); targetEl.appendChild(img);
} }
function clearThinkingCatIfPresent(targetEl){ function clearThinkingCatIfPresent(targetEl){
const img = targetEl.querySelector('img.thinking-cat'); const img=targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = ''; if(img) targetEl.innerHTML='';
}
// --- scroll indicator logic ---
function needsScroll(){
// Show only if content taller than container by a small epsilon
return (mainScroll.scrollHeight - mainScroll.clientHeight) > 4;
}
function atBottom(){
return (mainScroll.scrollTop + mainScroll.clientHeight) >= (mainScroll.scrollHeight - 4);
}
function updateScrollIndicator(){
if (needsScroll() && !atBottom()) scrollDownBtn.classList.add('show');
else scrollDownBtn.classList.remove('show');
} }
// --- Composer lock --- // Click: scroll the **conversation area** to bottom (not the page)
scrollDownBtn.addEventListener('click', () => {
mainScroll.scrollTo({ top: mainScroll.scrollHeight, behavior: 'smooth' });
// re-check after the animation
setTimeout(updateScrollIndicator, 350);
});
// Keep indicator state in sync
mainScroll.addEventListener('scroll', updateScrollIndicator);
window.addEventListener('resize', updateScrollIndicator);
// --- composer lock ---
function setComposerLocked(locked){ function setComposerLocked(locked){
isRunning = locked; isRunning=locked;
inputText.placeholder = locked ? t('status.thinking','Thinking…') : t('composer.placeholder','What can I help you with?'); inputText.placeholder = locked ? t('status.thinking','Thinking…') : t('composer.placeholder','What can I help you with?');
inputText.disabled = locked; inputText.disabled=locked;
goBtn.style.display = locked ? 'none' : 'inline-block'; goBtn.style.display=locked?'none':'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none'; stopBtn.style.display=locked?'inline-block':'none';
if (locked) inputText.blur(); else inputText.focus(); if (locked) inputText.blur(); else inputText.focus();
} }
// --- Flow --- // --- flow ---
async function start(){ async function start(){
const prompt = inputText.value.trim(); const prompt=inputText.value.trim();
if (!prompt) return; if(!prompt) return;
if (!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone = true; } if(!firstRequestDone){ welcomeLogo?.remove(); firstRequestDone=true; }
const shouldAutoScroll = atBottom(); const auto = atBottom();
const userMsg = document.createElement('div'); const userMsg=document.createElement('div');
userMsg.className = 'message user-message'; userMsg.className='message user-message';
userMsg.textContent = prompt; userMsg.textContent=prompt;
thread.appendChild(userMsg); thread.appendChild(userMsg);
const assistantMsg = document.createElement('div'); const assistantMsg=document.createElement('div');
assistantMsg.className = 'message assistant-message'; assistantMsg.className='message assistant-message';
showThinkingCat(assistantMsg); showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg); thread.appendChild(assistantMsg);
if (shouldAutoScroll) assistantMsg.scrollIntoView({ behavior:'smooth', block:'start' }); if (auto) assistantMsg.scrollIntoView({ behavior:'smooth', block:'start' });
updateScrollIndicator();
inputText.value = ''; inputText.value='';
controller = new AbortController(); controller=new AbortController();
setComposerLocked(true); setComposerLocked(true);
try{ try{
const res = await fetch(ENDPOINT, { const res=await fetch(ENDPOINT,{
method:'POST', method:'POST',
headers:{ 'Content-Type':'application/json' }, headers:{'Content-Type':'application/json'},
body:JSON.stringify({ content: prompt }), body:JSON.stringify({ content: prompt }),
signal:controller.signal signal:controller.signal
}); });
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); if(!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader(); const reader=res.body.getReader();
const decoder = new TextDecoder(); const decoder=new TextDecoder();
let rawMarkdown = ''; let rawMarkdown=''; let receivedAny=false; let rafPending=false;
let receivedAny = false;
let rafPending = false;
const flush = () => { const flush=() => {
if (rafPending) return; if (rafPending) return;
rafPending = true; rafPending=true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
rafPending = false; rafPending=false;
if (!receivedAny){ clearThinkingCatIfPresent(assistantMsg); receivedAny = true; } if(!receivedAny){ clearThinkingCatIfPresent(assistantMsg); receivedAny=true; }
renderMarkdown(assistantMsg, rawMarkdown); renderMarkdown(assistantMsg, rawMarkdown);
if (shouldAutoScroll && atBottom()){ if (auto && atBottom()){
mainScroll.scrollTop = mainScroll.scrollHeight; mainScroll.scrollTop = mainScroll.scrollHeight;
} }
updateScrollIndicator();
}); });
}; };
...@@ -330,9 +277,9 @@ ...@@ -330,9 +277,9 @@
} }
flush(); flush();
}catch(err){ }catch(err){
if (err.name !== 'AbortError'){ if (err.name!=='AbortError'){
clearThinkingCatIfPresent(assistantMsg); clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel','Error')}:** ${err.message || err}`); renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel','Error')}:** ${err.message||err}`);
}else{ }else{
if (assistantMsg.querySelector('img.thinking-cat')){ if (assistantMsg.querySelector('img.thinking-cat')){
clearThinkingCatIfPresent(assistantMsg); clearThinkingCatIfPresent(assistantMsg);
...@@ -341,48 +288,29 @@ ...@@ -341,48 +288,29 @@
} }
}finally{ }finally{
setComposerLocked(false); setComposerLocked(false);
controller = null; controller=null;
updateScrollIndicator();
} }
} }
async function stop(){ async function stop(){
if (controller) controller.abort(); if(controller) controller.abort();
try{ await fetch(STOPPOINT, { method:'POST' }); } catch(_){} try{ await fetch(STOPPOINT,{method:'POST'}); }catch(_){}
}
// --- Utilities for bottom detection and button action ---
function atBottom(){
return (mainScroll.scrollTop + mainScroll.clientHeight) >= (mainScroll.scrollHeight - 2);
} }
// Click = jump to bottom of the scrollable conversation // events
scrollDownBtn.addEventListener('click', () => { promptForm.addEventListener('submit', e => { e.preventDefault(); if (goBtn.style.display!=='none') start(); });
mainScroll.scrollTop = mainScroll.scrollHeight; inputText.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display!=='none'){ e.preventDefault(); start(); }
}); });
stopBtn.addEventListener('click', stop);
// Events // init
document.getElementById('promptForm').addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
document.getElementById('inputText').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && goBtn.style.display !== 'none'){
e.preventDefault();
start();
}
});
document.getElementById('stopBtn').addEventListener('click', stop);
// Init
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
try{ await fetch('/api/clear', { method:'GET' }); } catch(_){} try{ await fetch('/api/clear',{method:'GET'}); }catch(_){}
await loadLocale(CURRENT_LOCALE); await loadLocale(CURRENT_LOCALE);
document.getElementById('inputText').focus(); inputText.focus();
}); updateScrollIndicator(); // initial state
// Keep the fixed button correctly above the composer+footer on resize/orientation
window.addEventListener('resize', () => {
// nothing needed; CSS calc() handles it
}); });
</script> </script>
</body> </body>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment