Spaces:
Sleeping
Sleeping
Download static/script.js from konniedeveloping/intyoza: direct link, hf CLI and curl.
- Browser
- Download file 29.8 kB
-
https://huggingface.co/spaces/konniedeveloping/intyoza/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/konniedeveloping/intyoza/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/konniedeveloping/intyoza/resolve/main/static/script.js
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, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |
| 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(); |