Spaces:
Running
Running
| /* βββ Theme System ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| (function () { | |
| const saved = localStorage.getItem('learnix-theme') || 'dark'; | |
| document.documentElement.setAttribute('data-theme', saved); | |
| })(); | |
| let page = 1; | |
| /* βββ Theme Management ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function initTheme() { | |
| const saved = localStorage.getItem('learnix-theme') || 'dark'; | |
| applyTheme(saved); | |
| } | |
| function applyTheme(theme) { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem('learnix-theme', theme); | |
| updateSettingsUI(theme); | |
| } | |
| function updateSettingsUI(theme) { | |
| // Update base toggle | |
| document.querySelectorAll('.theme-base-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.base === (isDarkVariant(theme) ? 'dark' : 'light')); | |
| }); | |
| // Update swatches | |
| document.querySelectorAll('.swatch').forEach(s => { | |
| s.classList.toggle('active', s.dataset.theme === theme); | |
| }); | |
| } | |
| function isDarkVariant(theme) { | |
| return ['dark', 'emerald', 'rose', 'amber'].includes(theme); | |
| } | |
| /* βββ Settings Popup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function openSettings() { | |
| document.getElementById('settingsOverlay').classList.add('open'); | |
| document.getElementById('settingsPopup').classList.add('open'); | |
| } | |
| function closeSettings() { | |
| document.getElementById('settingsOverlay').classList.remove('open'); | |
| document.getElementById('settingsPopup').classList.remove('open'); | |
| } | |
| /* βββ Toast Notification ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function showToast(msg, icon = 'β') { | |
| let toast = document.getElementById('toast'); | |
| toast.innerHTML = `<span>${icon}</span><span>${msg}</span>`; | |
| toast.classList.remove('hide'); | |
| toast.classList.add('show'); | |
| clearTimeout(toast._timeout); | |
| toast._timeout = setTimeout(() => { | |
| toast.classList.remove('show'); | |
| toast.classList.add('hide'); | |
| }, 2800); | |
| } | |
| /* βββ Chat ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function getTime() { | |
| return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| function appendMsg(content, role) { | |
| const chatBox = document.getElementById('chatBox'); | |
| // Remove empty state | |
| const empty = chatBox.querySelector('.empty-chat'); | |
| if (empty) empty.remove(); | |
| const isUser = role === 'user'; | |
| const row = document.createElement('div'); | |
| row.className = `msg-row ${isUser ? 'user-row user' : 'bot'}`; | |
| row.innerHTML = ` | |
| <div class="msg-avatar ${isUser ? 'user-av' : 'bot-av'}">${isUser ? 'U' : 'AI'}</div> | |
| <div class="msg-meta"> | |
| <div class="msg-bubble">${content}</div> | |
| <span class="msg-time">${getTime()}</span> | |
| </div> | |
| `; | |
| chatBox.appendChild(row); | |
| chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: 'smooth' }); | |
| return row; | |
| } | |
| function showTyping() { | |
| const chatBox = document.getElementById('chatBox'); | |
| const row = document.createElement('div'); | |
| row.className = 'typing-row'; | |
| row.id = 'typingIndicator'; | |
| row.innerHTML = ` | |
| <div class="msg-avatar bot-av">AI</div> | |
| <div class="typing-bubble"> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| `; | |
| chatBox.appendChild(row); | |
| chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: 'smooth' }); | |
| } | |
| function removeTyping() { | |
| const el = document.getElementById('typingIndicator'); | |
| if (el) el.remove(); | |
| } | |
| async function sendMessage() { | |
| const input = document.getElementById('messageInput'); | |
| const msg = input.value.trim(); | |
| if (!msg) return; | |
| input.value = ''; | |
| input.disabled = true; | |
| const sendBtn = document.getElementById('sendBtn'); | |
| sendBtn.classList.add('sending'); | |
| setTimeout(() => sendBtn.classList.remove('sending'), 500); | |
| appendMsg(msg, 'user'); | |
| showTyping(); | |
| try { | |
| const res = await fetch('/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: msg }) | |
| }); | |
| const data = await res.json(); | |
| removeTyping(); | |
| appendMsg(data.answer, 'bot'); | |
| } catch (err) { | |
| removeTyping(); | |
| appendMsg('β Could not reach the server.', 'bot'); | |
| } finally { | |
| input.disabled = false; | |
| input.focus(); | |
| } | |
| } | |
| /* βββ Records βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function makeRow(rowData, i, prepend = false) { | |
| const tr = document.createElement('tr'); | |
| tr.style.animationDelay = prepend ? '0s' : `${i * 0.04}s`; | |
| if (prepend) tr.classList.add('row-new'); | |
| tr.innerHTML = ` | |
| <td contenteditable="true">${rowData.question}</td> | |
| <td contenteditable="true">${rowData.answer}</td> | |
| <td><button class="save-btn" onclick="saveEdit(this)">Save</button></td> | |
| `; | |
| return tr; | |
| } | |
| async function loadRecords(reset = false) { | |
| if (reset) { | |
| page = 1; | |
| document.querySelector('#recordsTable tbody').innerHTML = ''; | |
| const vm = document.getElementById('viewMore'); | |
| vm.style.display = ''; | |
| } | |
| const btn = document.getElementById('viewMore'); | |
| btn.classList.add('loading'); | |
| btn.querySelector('span').innerHTML = `<svg class="load-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> Loading...`; | |
| const res = await fetch(`/records?page=${page}`); | |
| const data = await res.json(); | |
| const tbody = document.querySelector('#recordsTable tbody'); | |
| data.records.forEach((row, i) => { | |
| tbody.appendChild(makeRow(row, i)); | |
| }); | |
| btn.classList.remove('loading'); | |
| btn.querySelector('span').innerHTML = ` | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> | |
| View More | |
| `; | |
| if (data.records.length < 30) { | |
| document.getElementById('viewMore').style.display = 'none'; | |
| } | |
| } | |
| async function refreshRecords() { | |
| const refreshBtn = document.getElementById('refreshBtn'); | |
| refreshBtn.classList.add('spinning'); | |
| await loadRecords(true); | |
| setTimeout(() => refreshBtn.classList.remove('spinning'), 600); | |
| showToast('Knowledge base refreshed', 'βΊ'); | |
| } | |
| async function saveEdit(btn) { | |
| const row = btn.parentNode.parentNode; | |
| const q = row.children[0].innerText; | |
| const a = row.children[1].innerText; | |
| btn.textContent = '...'; | |
| btn.disabled = true; | |
| await fetch('/update', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ old_question: q, question: q, answer: a }) | |
| }); | |
| btn.textContent = 'Save'; | |
| btn.disabled = false; | |
| showToast('Record updated', 'β'); | |
| } | |
| async function addRecord() { | |
| const qEl = document.getElementById('newQ'); | |
| const aEl = document.getElementById('newA'); | |
| const q = qEl.value.trim(); | |
| const a = aEl.value.trim(); | |
| if (!q || !a) { showToast('Fill in both fields', 'β '); return; } | |
| await fetch('/add', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ question: q, answer: a }) | |
| }); | |
| // Prepend new row to top of table without reloading page | |
| const tbody = document.querySelector('#recordsTable tbody'); | |
| const tr = makeRow({ question: q, answer: a }, 0, true); | |
| tbody.insertBefore(tr, tbody.firstChild); | |
| qEl.value = ''; | |
| aEl.value = ''; | |
| showToast('Record added', 'β'); | |
| } | |
| function viewMore() { | |
| page++; | |
| loadRecords(); | |
| } | |
| /* βββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initTheme(); | |
| loadRecords(); | |
| // Enter key to send | |
| document.getElementById('messageInput').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // Close settings on overlay click | |
| document.getElementById('settingsOverlay').addEventListener('click', closeSettings); | |
| // Theme base buttons | |
| document.querySelectorAll('.theme-base-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const base = btn.dataset.base; | |
| const current = localStorage.getItem('learnix-theme') || 'dark'; | |
| // Switch base while keeping color if applicable | |
| if (base === 'light') applyTheme('light'); | |
| else applyTheme('dark'); | |
| }); | |
| }); | |
| // Swatch clicks | |
| document.querySelectorAll('.swatch').forEach(swatch => { | |
| swatch.addEventListener('click', () => { | |
| applyTheme(swatch.dataset.theme); | |
| }); | |
| }); | |
| }); |