intyoza / static /script.js
konniedeveloping's picture
Update static/script.js
b376817 verified
Raw History Blame Contribute Delete
29.8 kB
/* Intyoza chat page. Needs common.js loaded first (t, getLang, setLang, setTheme, ...). */
let chatId = null, user = null, userInfo = null, guest = [], authMode = "login", allChats = [];
const $ = id => document.getElementById(id);
async function api(path, opts = {}) {
try {
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, credentials: "include", ...opts });
return await res.json();
} catch (e) {
return { ok: false, message: t("net_err") };
}
}
const initial = n => (n || "?")[0].toUpperCase();
const avatarMarkup = (n, a) => a ? `<img src="${a}" alt="">` : initial(n);
const displayTitle = s => (s === "New chat" ? t("new_chat") : s);
let toastTimer;
function toast(msg) {
let el = $("toast");
if (!el) { el = document.createElement("div"); el.id = "toast"; document.body.appendChild(el); }
el.textContent = msg;
el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("show"), 2200);
}
/* ---------- message formatting ---------- */
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatMessage(text) {
let safe = escapeHtml(text);
safe = safe.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
safe = safe.replace(/\*(.+?)\*/g, "<em>$1</em>");
// words with a circumflex = amasaku/ubutinde marks (any letters, apostrophes allowed)
safe = safe.replace(/[\p{L}']*[âêîôûÂÊÎÔÛ][\p{L}']*/gu, '<span class="amasaku">$&</span>');
return safe;
}
/* ---------- icons ---------- */
const ICON = {
copy: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>`,
del: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
mark: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`,
};
/* ---------- messages ---------- */
function makeRow(id, role, text, imageUrl, isError, sources) {
const row = document.createElement("div");
row.className = "row " + (role === "user" ? "user" : "bot");
if (id) row.dataset.id = id;
const b = document.createElement("div");
b.className = "bubble" + (isError ? " err" : "");
if (imageUrl) {
const img = document.createElement("img");
img.src = imageUrl; img.className = "bubble-image";
b.appendChild(img);
}
if (text) {
const p = document.createElement("div");
p.innerHTML = formatMessage(text);
b.appendChild(p);
}
const acts = document.createElement("div");
acts.className = "acts";
const mkBtn = (icon, titleKey) => {
const el = document.createElement("button");
el.className = "act"; el.title = t(titleKey); el.dataset.titleKey = titleKey;
el.innerHTML = icon;
return el;
};
const copy = mkBtn(ICON.copy, "copy");
copy.onclick = () => {
navigator.clipboard.writeText(text || "");
copy.innerHTML = ICON.check;
setTimeout(() => (copy.innerHTML = ICON.copy), 1400);
};
acts.appendChild(copy);
if (role !== "user" && !isError && text) {
const mark = mkBtn(ICON.mark, "save_phrase");
mark.onclick = async () => {
if (!user) { toast(t("saved_login")); return; }
const d = await api("/api/saved", { method: "POST", body: JSON.stringify({ text }) });
if (d.ok) { mark.classList.add("on"); toast(t("saved_ok")); }
};
acts.appendChild(mark);
}
const del = mkBtn(ICON.del, "del");
del.onclick = async () => {
if (row.dataset.id) await api(`/api/messages/${row.dataset.id}`, { method: "DELETE" });
row.remove(); checkEmpty();
};
acts.appendChild(del);
const src = renderSources(sources);
if (src) row.append(b, src, acts); else row.append(b, acts);
return row;
}
/* collapsible "Inkomoko (n)" list under a reply (news from igihe.com etc.) */
function renderSources(sources) {
if (!sources || !sources.length) return null;
const d = document.createElement("details");
d.className = "sources";
const sm = document.createElement("summary");
sm.textContent = `${t("sources_label")} (${sources.length})`;
const ul = document.createElement("ul");
sources.forEach(s => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = s.url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = s.title;
const host = document.createElement("small");
let h = ""; try { h = new URL(s.url).hostname.replace(/^www\./, ""); } catch (e) {}
host.textContent = [h, s.section].filter(Boolean).join(" · ");
li.append(a, host);
ul.appendChild(li);
});
d.append(sm, ul);
return d;
}
/* suggested follow-up questions under the latest reply */
let suggestSeq = 0;
function clearSuggestions() { suggestSeq++; document.querySelectorAll(".suggest").forEach(e => e.remove()); }
function showSuggestions(list) {
document.querySelectorAll(".suggest").forEach(e => e.remove());
if (!list || !list.length) return;
const box = document.createElement("div");
box.className = "suggest";
const lab = document.createElement("p");
lab.className = "suggest-label"; lab.textContent = t("suggest_label");
box.appendChild(lab);
list.forEach(q => {
const b = document.createElement("button");
b.className = "suggest-chip"; b.textContent = q;
b.onclick = () => { $("msg-box").value = q; send(); };
box.appendChild(b);
});
$("chatbox").appendChild(box);
$("chatbox").scrollTop = $("chatbox").scrollHeight;
}
async function fetchSuggestions(userText, reply, messageId) {
const seq = ++suggestSeq;
const d = await api("/api/suggest", { method: "POST", body: JSON.stringify({ user: userText, assistant: reply, message_id: messageId || null }) });
if (seq === suggestSeq && d.ok) showSuggestions(d.suggestions);
}
function checkEmpty() {
const box = $("chatbox");
if (!box.querySelector(".row") && !box.querySelector("#empty-state")) renderEmptyState();
}
function renderEmptyState() {
$("chatbox").innerHTML = `<div id="empty-state" class="empty-state">
<div class="empty-orb"></div><h2 data-i18n="empty_h"></h2>
<p data-i18n="empty_p"></p>
<div class="chip-row">
<button class="chip" data-i18n="chip1"></button>
<button class="chip" data-i18n="chip2"></button>
<button class="chip" data-i18n="chip3"></button>
<button class="chip" data-i18n="chip4"></button>
</div></div>`;
applyI18n();
bindChips();
}
function bindChips() {
document.querySelectorAll(".chip").forEach(c => c.onclick = () => { $("msg-box").value = c.textContent; send(); });
}
function render(list) {
const box = $("chatbox"); box.innerHTML = "";
if (!list.length) { renderEmptyState(); return; }
list.forEach(m => box.appendChild(makeRow(m.id, m.role, m.content, null, false, m.sources)));
const last = list[list.length - 1];
if (last && last.role !== "user" && last.suggestions && last.suggestions.length) showSuggestions(last.suggestions);
box.scrollTop = box.scrollHeight;
}
/* ---------- thinking indicator (rotating simple phrases) ---------- */
let thinkTimer = null;
function showTyping() {
const r = document.createElement("div");
r.className = "row bot typing"; r.id = "typing";
r.innerHTML = `<div class="bubble"><span class="dot"></span><span class="dot"></span><span class="dot"></span><span class="think-text"></span></div>`;
$("chatbox").appendChild(r);
$("chatbox").scrollTop = $("chatbox").scrollHeight;
const el = r.querySelector(".think-text");
const phrases = t("think");
let i = 0;
el.textContent = phrases[0];
thinkTimer = setInterval(() => {
el.classList.add("fade");
setTimeout(() => { i = (i + 1) % phrases.length; el.textContent = phrases[i]; el.classList.remove("fade"); }, 260);
}, 2600);
}
function hideTyping() {
clearInterval(thinkTimer); thinkTimer = null;
$("typing")?.remove();
}
/* ---------- sidebar ---------- */
function closeMenus() {
document.querySelectorAll(".menu.open").forEach(m => m.classList.remove("open"));
document.querySelectorAll(".dots-btn.open").forEach(b => b.classList.remove("open"));
}
document.addEventListener("click", closeMenus);
$("chat-list").addEventListener("scroll", closeMenus, { passive: true });
function paintChats(list) {
const wrap = $("chat-list"); wrap.innerHTML = "";
$("chat-empty").classList.toggle("hidden", list.length > 0 || !user);
list.forEach(c => {
const row = document.createElement("div");
row.className = "chat-row" + (c.id === chatId ? " active" : "");
const name = document.createElement("div");
name.className = "chat-name"; name.textContent = displayTitle(c.title); name.title = displayTitle(c.title);
name.onclick = () => openChat(c.id, c.title);
const dots = document.createElement("button");
dots.className = "dots-btn";
dots.innerHTML = `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>`;
const menu = document.createElement("div");
menu.className = "menu";
menu.innerHTML = `
<div class="menu-item" data-a="rename"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>${t("rename")}</div>
<div class="menu-item danger" data-a="delete"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>${t("del")}</div>`;
dots.onclick = e => {
e.stopPropagation();
const wasOpen = menu.classList.contains("open");
closeMenus();
if (!wasOpen) {
const r = dots.getBoundingClientRect();
menu.style.top = (r.bottom + 6) + "px";
menu.style.left = Math.min(r.left, window.innerWidth - 170) + "px";
menu.classList.add("open");
dots.classList.add("open");
}
};
menu.querySelector('[data-a="rename"]').onclick = e => { e.stopPropagation(); closeMenus(); inlineRename(row, name, c); };
menu.querySelector('[data-a="delete"]').onclick = async e => {
e.stopPropagation(); closeMenus();
await api(`/api/chats/${c.id}`, { method: "DELETE" });
const rest = await loadChats();
if (c.id === chatId) { rest.length ? openChat(rest[0].id, rest[0].title) : newChat(); }
};
row.append(dots, name, menu); wrap.appendChild(row);
});
}
function inlineRename(row, name, c) {
const inp = document.createElement("input");
inp.className = "rename-input"; inp.value = displayTitle(c.title);
row.replaceChild(inp, name); inp.focus(); inp.select();
let done = false;
const commit = async () => {
if (done) return; done = true;
const title = inp.value.trim() || "New chat";
await api(`/api/chats/${c.id}`, { method: "PATCH", body: JSON.stringify({ title }) });
if (c.id === chatId) $("chat-title").textContent = displayTitle(title);
loadChats();
};
inp.onkeydown = e => { if (e.key === "Enter") inp.blur(); if (e.key === "Escape") { done = true; loadChats(); } };
inp.onblur = commit;
inp.onclick = e => e.stopPropagation();
}
async function loadChats() {
const d = await api("/api/chats");
allChats = d.chats || [];
applySearch();
return allChats;
}
function applySearch() {
const q = $("chat-search").value.trim().toLowerCase();
paintChats(q ? allChats.filter(c => displayTitle(c.title).toLowerCase().includes(q)) : allChats);
}
$("chat-search").oninput = applySearch;
async function openChat(id, title) {
chatId = id; $("chat-title").textContent = displayTitle(title);
const d = await api(`/api/chats/${id}/messages`);
render(d.messages || []); applySearch();
}
async function newChat() {
if (!user) { chatId = null; guest = []; render([]); $("chat-title").textContent = t("new_chat"); return; }
const d = await api("/api/chats", { method: "POST" });
if (d.ok) { chatId = d.id; $("chat-title").textContent = displayTitle(d.title); render([]); loadChats(); }
}
/* ---------- attach image ---------- */
let pendingImage = null;
$("attach-btn").onclick = () => $("attach-input").click();
$("attach-input").onchange = e => {
const f = e.target.files[0]; if (!f) return;
if (f.size > 4_000_000) { alert(t("img_big")); return; }
const r = new FileReader();
r.onload = () => {
pendingImage = r.result;
$("attach-thumb").src = pendingImage;
$("attach-preview").classList.remove("hidden");
};
r.readAsDataURL(f);
};
$("attach-remove").onclick = () => {
pendingImage = null;
$("attach-input").value = "";
$("attach-preview").classList.add("hidden");
};
/* ---------- microphone (Web Speech API) ---------- */
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognizer = null, listening = false;
if (SR) {
recognizer = new SR();
recognizer.continuous = true;
recognizer.interimResults = true;
recognizer.onresult = e => {
let transcript = "";
for (let i = 0; i < e.results.length; i++) transcript += e.results[i][0].transcript;
$("msg-box").value = transcript;
$("msg-box").dispatchEvent(new Event("input"));
};
recognizer.onend = () => { listening = false; $("mic-btn").classList.remove("recording"); };
recognizer.onerror = e => {
listening = false; $("mic-btn").classList.remove("recording");
if (e.error === "not-allowed") alert(t("mic_blocked"));
if (e.error === "language-not-supported") toast(t("mic_lang"));
};
} else {
$("mic-btn").style.display = "none";
}
$("mic-btn").onclick = () => {
if (!recognizer) return;
if (listening) { recognizer.stop(); return; }
// Speech language follows the interface language (Kinyarwanda support depends on the browser).
recognizer.lang = getLang() === "rw" ? "rw-RW" : "en-US";
try { recognizer.start(); listening = true; $("mic-btn").classList.add("recording"); }
catch (e) { /* already running */ }
};
/* ---------- send ---------- */
let activeController = null;
const sendIconSVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`;
const stopIconSVG = `<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
let thinking = false;
function setThinking(on) {
thinking = on;
const btn = $("send-btn");
if (on) {
btn.innerHTML = stopIconSVG; btn.title = t("stop");
btn.classList.add("stopping");
btn.onclick = () => { if (activeController) activeController.abort(); };
} else {
btn.innerHTML = sendIconSVG; btn.title = t("send");
btn.classList.remove("stopping");
btn.onclick = send;
}
}
function aiErrorText(d) {
if (d.code === "rate_limited") return t("rate_limited");
if (d.code && I18N_HAS("err_" + d.code)) return t("err_" + d.code);
return d.message || t("something_wrong");
}
const I18N_HAS = k => t(k) !== k;
async function send() {
if (thinking) return;
const box = $("msg-box");
const text = box.value.trim();
const imageToSend = pendingImage;
if (!text && !imageToSend) return;
if (listening && recognizer) recognizer.stop();
clearSuggestions();
box.value = ""; box.style.height = "auto";
$("empty-state")?.remove();
const userRow = makeRow(null, "user", text, imageToSend);
$("chatbox").appendChild(userRow);
$("chatbox").scrollTop = $("chatbox").scrollHeight;
// No "mode" any more: the tutor adapts to the learner by itself on the server.
const payload = { message: text };
if (imageToSend) payload.image = imageToSend;
if (user && chatId) payload.chat_id = chatId; else payload.history = guest.slice(-20);
pendingImage = null; $("attach-input").value = ""; $("attach-preview").classList.add("hidden");
showTyping();
setThinking(true);
activeController = new AbortController();
let d;
try {
const res = await fetch("/api/message", {
method: "POST", headers: { "Content-Type": "application/json" },
credentials: "include", body: JSON.stringify(payload),
signal: activeController.signal,
});
d = await res.json();
} catch (e) {
if (e.name === "AbortError") { hideTyping(); setThinking(false); return; }
d = { ok: false, message: t("net_err") };
}
setThinking(false);
hideTyping();
if (!d.ok) {
$("chatbox").appendChild(makeRow(null, "bot", aiErrorText(d), null, true));
$("chatbox").scrollTop = $("chatbox").scrollHeight;
return;
}
if (d.user_message_id) userRow.dataset.id = d.user_message_id;
$("chatbox").appendChild(makeRow(d.assistant_message_id, "bot", d.reply, null, false, d.sources));
$("chatbox").scrollTop = $("chatbox").scrollHeight;
fetchSuggestions(text, d.reply, d.assistant_message_id);
if (d.new_title) { $("chat-title").textContent = displayTitle(d.new_title); loadChats(); }
else if (user) loadChats();
if (!user) guest.push({ role: "user", content: text }, { role: "assistant", content: d.reply });
}
/* ---------- auth ---------- */
function paintAuth() {
const login = authMode === "login";
$("auth-title").textContent = t(login ? "auth_title_login" : "auth_title_signup");
$("auth-sub").textContent = t(login ? "auth_sub_login" : "auth_sub_signup");
$("auth-submit").querySelector(".btn-label").textContent = t(login ? "login" : "signup");
$("switch-text").textContent = t(login ? "new_here" : "have_acct");
$("auth-switch").textContent = t(login ? "create_acct" : "login_instead");
}
function setAuthMode(m) {
authMode = m;
paintAuth();
$("auth-status").textContent = ""; $("auth-status").className = "status";
}
function loading(on) {
const b = $("auth-submit");
b.disabled = on;
b.querySelector(".spinner").classList.toggle("hidden", !on);
b.querySelector(".btn-label").style.opacity = on ? ".55" : "1";
}
$("auth-switch").onclick = () => setAuthMode(authMode === "login" ? "signup" : "login");
$("login-nav-btn").onclick = () => { setAuthMode("login"); showAuth(); };
$("signup-nav-btn").onclick = () => { setAuthMode("signup"); showAuth(); };
function showAuth() { $("auth-view").classList.remove("hidden"); $("app-view").classList.add("hidden"); }
function showApp() { $("auth-view").classList.add("hidden"); $("app-view").classList.remove("hidden"); }
function authMessage(d) {
if (d.code && I18N_HAS("err_" + d.code)) return t("err_" + d.code);
return d.message || "";
}
$("auth-submit").onclick = async () => {
const username = $("auth-username").value.trim();
const password = $("auth-password").value;
const st = $("auth-status");
if (!username || !password) { st.textContent = t("err_missing_fields"); st.className = "status error"; return; }
loading(true);
const path = authMode === "login" ? "/api/login" : "/api/signup";
const d = await api(path, { method: "POST", body: JSON.stringify({ username, password }) });
loading(false);
st.textContent = d.ok ? "" : authMessage(d);
st.className = "status " + (d.ok ? "ok" : "error");
if (d.ok && authMode === "login") { await refresh(); }
else if (d.ok) { setAuthMode("login"); st.textContent = t("acct_created"); st.className = "status ok"; }
};
$("auth-password").onkeydown = e => { if (e.key === "Enter") $("auth-submit").click(); };
document.querySelectorAll(".oauth-btn").forEach(b => {
b.onclick = async () => {
const p = b.dataset.provider;
const s = await api("/api/oauth-status");
if (!s[p]) {
const n = $("oauth-note");
n.textContent = t("oauth_unconf", { p: p === "google" ? "Google" : "GitHub" });
n.classList.remove("hidden");
return;
}
window.location.href = `/auth/${p}`;
};
});
$("logout-btn").onclick = async () => { await api("/api/logout", { method: "POST" }); chatId = null; guest = []; closeDash(); await refresh(); };
/* ---------- dashboard ---------- */
function openDash() {
if (!user) return;
$("dash-modal").classList.remove("hidden");
$("dash-status").textContent = "";
fillDashHeader();
syncSegs();
$("set-name").value = userInfo?.display_name || user;
$("pw-box").classList.toggle("hidden", !userInfo?.has_password);
switchTab("overview");
loadDash();
}
function closeDash() { $("dash-modal").classList.add("hidden"); }
function fillDashHeader() {
const shown = userInfo?.display_name || user;
$("dash-name").textContent = shown;
const d = userInfo?.created_at ? new Date(userInfo.created_at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "";
$("dash-sub").textContent = d ? t("joined", { d }) : "@" + user;
$("modal-avatar").innerHTML = avatarMarkup(shown, userInfo?.avatar);
}
function switchTab(name) {
document.querySelectorAll(".tab").forEach(b => b.classList.toggle("active", b.dataset.tab === name));
document.querySelectorAll(".tab-pane").forEach(p => p.classList.toggle("hidden", p.id !== "tab-" + name));
if (name === "saved") loadSaved();
if (name === "overview") loadDash();
}
document.querySelectorAll(".tab").forEach(b => b.onclick = () => switchTab(b.dataset.tab));
$("profile-btn").onclick = openDash;
$("dash-close").onclick = closeDash;
$("modal-close").onclick = closeDash;
$("dash-modal").addEventListener("click", e => { if (e.target === $("dash-modal")) closeDash(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") closeDash(); });
async function loadDash() {
const d = await api(`/api/dashboard?tz=${new Date().getTimezoneOffset()}`);
if (!d.ok) return;
$("st-chats").textContent = d.stats.chats;
$("st-msgs").textContent = d.stats.messages;
$("st-days").textContent = d.stats.active_days;
$("st-streak").textContent = d.stats.streak;
$("st-saved").textContent = d.stats.saved;
const max = Math.max(1, ...d.activity.map(a => a.count));
const days = t("days");
$("chart").innerHTML = d.activity.map(a => {
const wd = days[new Date(a.date + "T00:00:00").getDay()];
const h = a.count ? Math.max(8, Math.round(a.count / max * 100)) : 0;
return `<div class="bar ${a.count ? "" : "empty"}" title="${a.count}"><i style="height:${h}%"></i><span>${wd}</span></div>`;
}).join("");
}
async function loadSaved() {
const d = await api("/api/saved");
const list = $("saved-list");
list.innerHTML = "";
const items = d.saved || [];
$("saved-empty").classList.toggle("hidden", items.length > 0);
items.forEach(s => {
const it = document.createElement("div");
it.className = "saved-item";
const p = document.createElement("div");
const txt = document.createElement("p"); txt.innerHTML = formatMessage(s.text);
const sm = document.createElement("small"); sm.textContent = new Date(s.created_at).toLocaleDateString();
p.style.flex = "1"; p.append(txt, sm);
const cp = document.createElement("button"); cp.className = "act"; cp.title = t("copy"); cp.innerHTML = ICON.copy;
cp.onclick = () => { navigator.clipboard.writeText(s.text); toast(t("copy")); };
const rm = document.createElement("button"); rm.className = "act"; rm.title = t("del"); rm.innerHTML = ICON.del;
rm.onclick = async () => { await api(`/api/saved/${s.id}`, { method: "DELETE" }); loadSaved(); loadDash(); };
it.append(p, cp, rm);
list.appendChild(it);
});
}
function dashStatus(msg, ok) {
const st = $("dash-status"); st.textContent = msg; st.className = "status " + (ok ? "ok" : "error");
}
$("set-name-save").onclick = async () => {
const name = $("set-name").value.trim();
if (!name) return;
const d = await api("/api/profile", { method: "PATCH", body: JSON.stringify({ display_name: name }) });
if (d.ok) {
userInfo.display_name = name; fillDashHeader(); paintNav(); paintWelcome();
dashStatus(t("name_saved"), true);
} else dashStatus(authMessage(d), false);
};
function syncSegs() {
document.querySelectorAll("[data-theme-val]").forEach(b => b.classList.toggle("active", b.dataset.themeVal === getTheme()));
document.querySelectorAll("[data-lang-val]").forEach(b => b.classList.toggle("active", b.dataset.langVal === getLang()));
}
document.querySelectorAll("[data-theme-val]").forEach(b => b.onclick = () => setTheme(b.dataset.themeVal));
document.querySelectorAll("[data-lang-val]").forEach(b => b.onclick = () => setLang(b.dataset.langVal));
$("pw-save").onclick = async () => {
const d = await api("/api/profile/password", { method: "POST", body: JSON.stringify({ current: $("pw-cur").value, new: $("pw-new").value }) });
if (d.ok) { $("pw-cur").value = ""; $("pw-new").value = ""; dashStatus(t("pw_ok"), true); }
else dashStatus(authMessage(d), false);
};
$("export-btn").onclick = async () => {
const res = await fetch("/api/export", { credentials: "include" });
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = "intyoza-export.json";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
};
$("clear-btn").onclick = async () => {
if (!confirm(t("confirm_clear"))) return;
await api("/api/chats/clear", { method: "POST" });
chatId = null; closeDash(); await refresh();
};
$("delete-acct-btn").onclick = async () => {
if (prompt(t("del_prompt")) !== "DELETE") return;
const d = await api("/api/account", { method: "DELETE", body: JSON.stringify({ confirm: "DELETE" }) });
if (d.ok) { chatId = null; guest = []; closeDash(); await refresh(); }
};
/* avatar: crop to a square and shrink before upload */
function resizeImage(file, size = 256) {
return new Promise((resolve, reject) => {
const img = new Image(); const url = URL.createObjectURL(file);
img.onload = () => {
const c = document.createElement("canvas"); c.width = c.height = size;
const s = Math.min(img.width, img.height);
c.getContext("2d").drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);
URL.revokeObjectURL(url); resolve(c.toDataURL("image/jpeg", 0.86));
};
img.onerror = reject; img.src = url;
});
}
$("avatar-input").onchange = async e => {
const f = e.target.files[0]; if (!f) return;
try {
const dataUrl = await resizeImage(f);
const d = await api("/api/profile/avatar", { method: "POST", body: JSON.stringify({ avatar: dataUrl }) });
if (d.ok) { userInfo.avatar = d.avatar; fillDashHeader(); paintNav(); }
} catch (err) { /* unreadable image: ignore */ }
};
/* ---------- session + repaint ---------- */
function paintNav() {
const shown = userInfo?.display_name || user;
$("nav-name").textContent = shown;
$("nav-avatar").innerHTML = avatarMarkup(shown, userInfo?.avatar);
}
function paintWelcome() {
$("welcome-line").textContent = user ? t("welcome_back", { name: userInfo?.display_name || user }) : t("panel_sub");
}
async function refresh() {
const d = await api("/api/session");
user = d.username || null;
userInfo = user ? d : null;
if (user) {
$("guest-nav").classList.add("hidden");
$("user-nav").classList.remove("hidden");
paintNav(); paintWelcome();
$("sidebar-hint").classList.add("hidden");
// Saved preferences follow the account; first time, push the local ones up.
if (d.prefs && (d.prefs.lang || d.prefs.theme)) {
if (d.prefs.theme && d.prefs.theme !== getTheme()) setTheme(d.prefs.theme, true);
if (d.prefs.lang && d.prefs.lang !== getLang()) setLang(d.prefs.lang, true);
} else {
api("/api/profile", { method: "PATCH", body: JSON.stringify({ prefs: { theme: getTheme(), lang: getLang() } }) });
}
const chats = await loadChats();
chats.length ? openChat(chats[0].id, chats[0].title) : await newChat();
} else {
$("guest-nav").classList.remove("hidden");
$("user-nav").classList.add("hidden");
paintWelcome();
$("sidebar-hint").classList.remove("hidden");
$("chat-empty").classList.add("hidden");
$("chat-list").innerHTML = "";
$("chat-title").textContent = t("new_chat");
render(guest);
}
showApp();
}
/* theme or language changed (from the header buttons, the dashboard, or the server) */
window.addEventListener("uichange", e => {
paintAuth(); paintWelcome(); syncSegs();
setThinking(thinking);
if (!$("dash-modal").classList.contains("hidden")) { fillDashHeader(); loadDash(); }
if (!user && !$("chatbox").querySelector(".row")) $("chat-title").textContent = t("new_chat");
if (user) {
applySearch();
const cur = allChats.find(c => c.id === chatId);
if (cur) $("chat-title").textContent = displayTitle(cur.title);
}
document.querySelectorAll(".act[data-title-key]").forEach(b => { b.title = t(b.dataset.titleKey); });
if (user && !e.detail.fromServer) {
api("/api/profile", { method: "PATCH", body: JSON.stringify({ prefs: { theme: e.detail.theme, lang: e.detail.lang } }) });
}
});
/* ---------- misc ---------- */
$("new-chat-btn").onclick = newChat;
$("msg-box").onkeydown = e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } };
$("msg-box").oninput = function () { this.style.height = "auto"; this.style.height = Math.min(this.scrollHeight, 150) + "px"; };
if (new URLSearchParams(location.search).get("oauth_error")) {
history.replaceState({}, "", "/chat");
}
setAuthMode("login");
bindChips();
setThinking(false);
syncSegs();
refresh();