Files
wiki/tasks/snowbike-rag/templates/index.html
2026-04-12 21:55:33 +03:00

787 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🏔️ Snowbike Поиск</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
bg: {
primary: '#0F172A',
card: '#1E293B',
input: '#334155',
},
accent: {
DEFAULT: '#3B82F6',
hover: '#2563EB',
},
border: '#334155',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
}
}
}
</script>
<!-- Marked.js -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--bg-primary: #0F172A;
--bg-card: #1E293B;
--bg-input: #334155;
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--accent: #3B82F6;
--accent-hover: #2563EB;
--error: #EF4444;
--border: #334155;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, sans-serif;
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
/* Markdown styles */
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
color: #F1F5F9;
font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.5em;
line-height: 1.3;
}
.markdown-body h1 { font-size: 1.35em; }
.markdown-body h2 { font-size: 1.2em; }
.markdown-body h3 { font-size: 1.05em; }
.markdown-body p {
margin: 0.6em 0;
line-height: 1.7;
color: #CBD5E1;
}
.markdown-body ul, .markdown-body ol {
padding-left: 1.5em;
margin: 0.5em 0;
color: #CBD5E1;
}
.markdown-body li { margin: 0.3em 0; line-height: 1.6; }
.markdown-body strong { color: #F1F5F9; font-weight: 600; }
.markdown-body em { color: #BAC9E0; }
.markdown-body code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.875em;
background: #0F172A;
border: 1px solid #334155;
border-radius: 4px;
padding: 0.15em 0.4em;
color: #7DD3FC;
}
.markdown-body pre {
background: #0F172A;
border: 1px solid #334155;
border-radius: 8px;
padding: 1em 1.2em;
overflow-x: auto;
margin: 0.8em 0;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85em;
color: #E2E8F0;
}
.markdown-body blockquote {
border-left: 3px solid var(--accent);
margin: 0.75em 0;
padding: 0.5em 1em;
background: rgba(59, 130, 246, 0.08);
border-radius: 0 6px 6px 0;
color: #94A3B8;
}
.markdown-body a {
color: #60A5FA;
text-decoration: none;
}
.markdown-body a:hover { text-decoration: underline; }
.markdown-body hr {
border: none;
border-top: 1px solid #334155;
margin: 1em 0;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fade-in {
animation: fadeIn 0.35s ease forwards;
}
/* Dots spinner */
.dot-spinner span {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
background: #60A5FA;
margin: 0 3px;
animation: pulse-dot 1.4s infinite ease-in-out;
}
.dot-spinner span:nth-child(2) { animation-delay: 0.2s; }
.dot-spinner span:nth-child(3) { animation-delay: 0.4s; }
/* Skeleton */
.skeleton {
background: linear-gradient(90deg, #1E293B 25%, #334155 50%, #1E293B 75%);
background-size: 200% 100%;
animation: shimmer 1.6s infinite;
border-radius: 6px;
}
/* Textarea auto-resize */
textarea { resize: none; }
/* Source card hover */
.source-card {
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.source-card:hover {
transform: translateY(-1px);
border-color: #3B82F6;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.12);
}
/* Topic badge colors (cycling) */
.badge-0 { background: rgba(59, 130, 246, 0.2); color: #60A5FA; border: 1px solid rgba(59, 130, 246, 0.3); }
.badge-1 { background: rgba(16, 185, 129, 0.2); color: #34D399; border: 1px solid rgba(16, 185, 129, 0.3); }
.badge-2 { background: rgba(249, 115, 22, 0.2); color: #FB923C; border: 1px solid rgba(249, 115, 22, 0.3); }
.badge-3 { background: rgba(168, 85, 247, 0.2); color: #C084FC; border: 1px solid rgba(168, 85, 247, 0.3); }
.badge-4 { background: rgba(236, 72, 153, 0.2); color: #F472B6; border: 1px solid rgba(236, 72, 153, 0.3); }
.badge-5 { background: rgba(234, 179, 8, 0.2); color: #FBBF24; border: 1px solid rgba(234, 179, 8, 0.3); }
/* Toast */
.toast {
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 9999;
padding: 0.85rem 1.2rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 500;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
animation: fadeIn 0.25s ease;
}
.toast.error { background: #450A0A; border: 1px solid #EF4444; color: #FCA5A5; }
.toast.success { background: #052e16; border: 1px solid #10B981; color: #6EE7B7; }
/* Search input focus glow */
#query-input:focus {
outline: none;
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
#query-input.error-border { border-color: #EF4444 !important; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15) !important; }
/* Modal */
#stats-modal {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
z-index: 1000;
align-items: center;
justify-content: center;
}
#stats-modal.open { display: flex; animation: fadeIn 0.2s ease; }
/* History chip */
.history-chip {
cursor: pointer;
transition: background 0.15s;
}
.history-chip:hover { background: #334155; }
</style>
</head>
<body>
<!-- Toast container (JS-controlled) -->
<div id="toast-container"></div>
<!-- Header -->
<header class="border-b border-slate-800 sticky top-0 z-50" style="background: rgba(15,23,42,0.92); backdrop-filter: blur(12px);">
<div class="max-w-3xl mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🏔️</span>
<div>
<h1 class="text-lg font-bold text-slate-100 leading-tight">Snowbike Поиск</h1>
<p class="text-xs text-slate-500">База знаний по сноубайкам</p>
</div>
</div>
<button
id="stats-btn"
onclick="openStats()"
class="text-xs text-slate-500 hover:text-slate-300 transition-colors px-3 py-1.5 rounded-lg hover:bg-slate-800"
>О базе</button>
</div>
</header>
<!-- Main -->
<main class="max-w-3xl mx-auto px-4 py-8 pb-20">
<!-- Search Box -->
<section class="mb-8">
<div class="relative">
<!-- Textarea + button wrapper -->
<div id="search-box" class="rounded-2xl border border-slate-700 overflow-hidden transition-all duration-200" style="background: #1E293B;">
<textarea
id="query-input"
rows="2"
maxlength="1000"
placeholder="Спросите про сноубайки... Например: какое масло для Polaris 850?"
class="w-full px-5 pt-4 pb-2 text-base text-slate-100 placeholder-slate-500 border-0 bg-transparent leading-relaxed"
style="outline: none;"
oninput="autoResize(this); clearError()"
onkeydown="handleKey(event)"
></textarea>
<!-- Bottom bar -->
<div class="flex items-center justify-between px-4 pb-3 pt-1">
<span class="text-xs text-slate-600" id="char-count">0 / 1000</span>
<button
id="search-btn"
onclick="doSearch()"
class="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold transition-all duration-200"
style="background: #3B82F6; color: white;"
onmouseover="this.style.background='#2563EB'"
onmouseout="this.style.background='#3B82F6'"
>
<span>🔍</span>
<span>Найти</span>
</button>
</div>
</div>
<!-- Error hint -->
<p id="input-error" class="hidden text-xs text-red-400 mt-2 ml-1">Введите запрос (минимум 3 символа)</p>
</div>
<!-- History -->
<div id="history-block" class="hidden mt-3">
<p class="text-xs text-slate-600 mb-2">Недавние запросы:</p>
<div id="history-chips" class="flex flex-wrap gap-2"></div>
</div>
</section>
<!-- Loading skeleton -->
<section id="loading-section" class="hidden">
<div class="fade-in space-y-4">
<div class="rounded-2xl p-5 border border-slate-800" style="background: #1E293B;">
<div class="flex items-center gap-3 mb-4">
<div class="skeleton w-20 h-4"></div>
<div class="dot-spinner ml-auto">
<span></span><span></span><span></span>
</div>
</div>
<div class="space-y-2">
<div class="skeleton h-4 w-full"></div>
<div class="skeleton h-4 w-5/6"></div>
<div class="skeleton h-4 w-4/5"></div>
<div class="skeleton h-4 w-3/4 mt-4"></div>
<div class="skeleton h-4 w-5/6"></div>
</div>
</div>
<div class="skeleton h-10 w-36 rounded-xl"></div>
<div class="space-y-3">
<div class="skeleton h-24 w-full rounded-xl"></div>
<div class="skeleton h-24 w-full rounded-xl"></div>
</div>
</div>
</section>
<!-- Results -->
<section id="results-section" class="hidden">
<div class="fade-in space-y-6">
<!-- Meta -->
<div class="flex items-center gap-4 text-xs text-slate-500">
<span id="meta-time">⏱ 0 сек</span>
<span id="meta-count">📄 0 источников</span>
</div>
<!-- Answer -->
<div class="rounded-2xl p-6 border border-slate-700" style="background: #1E293B;">
<h2 class="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4">Ответ</h2>
<div id="answer-content" class="markdown-body text-sm leading-relaxed"></div>
</div>
<!-- Sources -->
<div id="sources-block">
<h2 class="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3" id="sources-title">Источники</h2>
<div id="sources-list" class="space-y-3"></div>
</div>
</div>
</section>
<!-- Empty state (first load) -->
<section id="empty-state" class="text-center py-16">
<div class="text-5xl mb-4">🏔️</div>
<p class="text-slate-400 text-base font-medium mb-2">База знаний по сноубайкам</p>
<p class="text-slate-600 text-sm">Задайте вопрос — найдём ответ в 155 000+ сообщениях</p>
<div class="mt-8 flex flex-wrap gap-2 justify-center">
<button onclick="fillExample(this)" class="example-chip text-xs px-3 py-1.5 rounded-full border border-slate-700 text-slate-400 hover:border-blue-500 hover:text-blue-400 transition-all">
Какое масло для Polaris 850?
</button>
<button onclick="fillExample(this)" class="example-chip text-xs px-3 py-1.5 rounded-full border border-slate-700 text-slate-400 hover:border-blue-500 hover:text-blue-400 transition-all">
Цепная передача vs ремень
</button>
<button onclick="fillExample(this)" class="example-chip text-xs px-3 py-1.5 rounded-full border border-slate-700 text-slate-400 hover:border-blue-500 hover:text-blue-400 transition-all">
Как настроить карбюратор?
</button>
<button onclick="fillExample(this)" class="example-chip text-xs px-3 py-1.5 rounded-full border border-slate-700 text-slate-400 hover:border-blue-500 hover:text-blue-400 transition-all">
Тюнинг подвески
</button>
</div>
</section>
</main>
<!-- Stats Modal -->
<div id="stats-modal" onclick="closeStatsOnBg(event)">
<div class="rounded-2xl border border-slate-700 p-6 w-full max-w-md mx-4" style="background: #1E293B;">
<div class="flex items-center justify-between mb-5">
<h3 class="text-base font-semibold text-slate-100">О базе данных</h3>
<button onclick="closeStats()" class="text-slate-500 hover:text-slate-300 text-xl leading-none"></button>
</div>
<div id="stats-content" class="space-y-3 text-sm">
<div class="flex items-center gap-3">
<div class="dot-spinner"><span></span><span></span><span></span></div>
<span class="text-slate-400">Загрузка...</span>
</div>
</div>
</div>
</div>
<script>
// ===================== Config =====================
const API_BASE = '/snowbike-rag';
// ===================== State =====================
let isLoading = false;
const HISTORY_KEY = 'snowbike_history';
const MAX_HISTORY = 8;
// Topic badge colors
const badgeCache = {};
let badgeIdx = 0;
function getBadgeClass(topic) {
if (!topic) return 'badge-0';
if (!(topic in badgeCache)) {
badgeCache[topic] = 'badge-' + (badgeIdx++ % 6);
}
return badgeCache[topic];
}
// ===================== Init =====================
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('query-input').focus();
renderHistory();
marked.setOptions({
breaks: true,
gfm: true,
sanitize: false,
});
});
// ===================== Auto-resize textarea =====================
function autoResize(el) {
el.style.height = 'auto';
const lines = el.value.split('\n').length;
const maxRows = 5;
el.rows = Math.min(Math.max(2, lines), maxRows);
el.style.height = el.scrollHeight + 'px';
document.getElementById('char-count').textContent = el.value.length + ' / 1000';
}
// ===================== Key handler =====================
function handleKey(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
doSearch();
}
}
// ===================== Example chips =====================
function fillExample(btn) {
const ta = document.getElementById('query-input');
ta.value = btn.textContent.trim();
autoResize(ta);
ta.focus();
}
// ===================== Validation =====================
function clearError() {
const ta = document.getElementById('query-input');
const err = document.getElementById('input-error');
ta.classList.remove('error-border');
err.classList.add('hidden');
}
function showInputError(msg) {
const ta = document.getElementById('query-input');
const err = document.getElementById('input-error');
ta.classList.add('error-border');
err.textContent = msg;
err.classList.remove('hidden');
}
// ===================== Search =====================
async function doSearch() {
if (isLoading) return;
const query = document.getElementById('query-input').value.trim();
if (!query) {
showInputError('Введите запрос');
return;
}
if (query.length < 3) {
showInputError('Введите более точный запрос (минимум 3 символа)');
return;
}
clearError();
setLoading(true);
saveHistory(query);
renderHistory();
const t0 = performance.now();
try {
const url = API_BASE + '/api/search?q=' + encodeURIComponent(query) + '&limit=10';
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + resp.status);
}
const data = await resp.json();
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
renderResults(data, elapsed);
} catch (err) {
console.error(err);
if (err.name === 'TypeError' && err.message.includes('fetch')) {
showToast('error', '🔌 Сервис временно недоступен. Попробуйте позже.');
} else {
showToast('error', '❌ ' + (err.message || 'Неизвестная ошибка'));
}
setLoading(false);
showSection('empty-state');
}
}
// ===================== Render Results =====================
function renderResults(data, elapsed) {
setLoading(false);
const answer = data.answer || '';
const sources = data.sources || [];
const timeMs = data.time_ms || (elapsed * 1000);
const count = data.count ?? sources.length;
// Meta
document.getElementById('meta-time').textContent = '⏱ ' + (timeMs / 1000).toFixed(1) + ' сек';
document.getElementById('meta-count').textContent = '📄 ' + count + ' источников';
// Answer
const answerEl = document.getElementById('answer-content');
if (answer) {
answerEl.innerHTML = marked.parse(answer);
} else {
answerEl.innerHTML = '<p class="text-slate-500 italic">Ответ не найден. Попробуйте переформулировать запрос.</p>';
}
// Sources
const sourcesBlock = document.getElementById('sources-block');
const sourcesList = document.getElementById('sources-list');
document.getElementById('sources-title').textContent = 'Источники (' + count + ')';
if (sources.length === 0) {
sourcesBlock.classList.add('hidden');
} else {
sourcesBlock.classList.remove('hidden');
sourcesList.innerHTML = '';
sources.forEach((src, i) => {
sourcesList.appendChild(buildSourceCard(src, i));
});
}
showSection('results-section');
}
// ===================== Source Card =====================
function buildSourceCard(src, idx) {
const div = document.createElement('div');
div.className = 'source-card rounded-xl p-4 border border-slate-700 cursor-default';
div.style.background = '#1E293B';
const topic = src.topic_title || src.topic || 'Без топика';
const badge = getBadgeClass(topic);
const date = formatDate(src.date || src.created_at);
const preview = truncate(src.text || src.content || '', 180);
const msgId = src.message_id || src.id;
const tgLink = msgId ? 'https://t.me/snowbikerussia/' + msgId : null;
const score = src.score != null ? (src.score * 100).toFixed(0) : null;
div.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs px-2 py-0.5 rounded-full font-medium ${badge}">${escapeHtml(topic)}</span>
${date ? `<span class="text-xs text-slate-500">${date}</span>` : ''}
</div>
${score !== null ? `<span class="text-xs text-slate-600 shrink-0">↑${score}%</span>` : ''}
</div>
<p class="text-xs text-slate-400 leading-relaxed line-clamp-3">${escapeHtml(preview)}</p>
${tgLink ? `<a href="${tgLink}" target="_blank" rel="noopener" class="inline-block mt-2 text-xs text-blue-500 hover:text-blue-400 transition-colors">Открыть в Telegram →</a>` : ''}
`;
return div;
}
// ===================== Helpers =====================
function formatDate(raw) {
if (!raw) return null;
try {
const d = new Date(raw);
if (isNaN(d)) return raw;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return raw; }
}
function truncate(str, n) {
if (str.length <= n) return str;
return str.slice(0, n).trim() + '…';
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ===================== UI States =====================
function setLoading(on) {
isLoading = on;
const btn = document.getElementById('search-btn');
const loading = document.getElementById('loading-section');
const results = document.getElementById('results-section');
const empty = document.getElementById('empty-state');
if (on) {
btn.disabled = true;
btn.style.background = '#1E40AF';
btn.innerHTML = `<span class="dot-spinner" style="display:inline-flex;align-items:center;gap:2px"><span></span><span></span><span></span></span>`;
loading.classList.remove('hidden');
results.classList.add('hidden');
empty.classList.add('hidden');
} else {
btn.disabled = false;
btn.style.background = '#3B82F6';
btn.innerHTML = '<span>🔍</span><span>Найти</span>';
loading.classList.add('hidden');
}
}
function showSection(id) {
['results-section', 'empty-state'].forEach(s => {
document.getElementById(s).classList.add('hidden');
});
const el = document.getElementById(id);
if (el) {
el.classList.remove('hidden');
// Re-trigger animation
el.classList.remove('fade-in');
void el.offsetWidth;
const inner = el.querySelector('.fade-in');
if (inner) {
inner.style.animation = 'none';
void inner.offsetWidth;
inner.style.animation = '';
}
}
}
// ===================== Toast =====================
function showToast(type, msg) {
const tc = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
tc.appendChild(t);
setTimeout(() => {
t.style.transition = 'opacity 0.3s';
t.style.opacity = '0';
setTimeout(() => t.remove(), 300);
}, 4000);
}
// ===================== History =====================
function saveHistory(query) {
let h = loadHistory();
h = h.filter(q => q !== query);
h.unshift(query);
h = h.slice(0, MAX_HISTORY);
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(h)); } catch {}
}
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
}
function renderHistory() {
const h = loadHistory();
const block = document.getElementById('history-block');
const chips = document.getElementById('history-chips');
if (h.length === 0) {
block.classList.add('hidden');
return;
}
block.classList.remove('hidden');
chips.innerHTML = '';
h.slice(0, 5).forEach(q => {
const btn = document.createElement('button');
btn.className = 'history-chip text-xs px-3 py-1 rounded-full border border-slate-700 text-slate-400';
btn.textContent = truncate(q, 40);
btn.onclick = () => {
document.getElementById('query-input').value = q;
autoResize(document.getElementById('query-input'));
doSearch();
};
chips.appendChild(btn);
});
}
// ===================== Stats Modal =====================
async function openStats() {
const modal = document.getElementById('stats-modal');
const content = document.getElementById('stats-content');
modal.classList.add('open');
content.innerHTML = `<div class="flex items-center gap-3"><div class="dot-spinner"><span></span><span></span><span></span></div><span class="text-slate-400">Загрузка...</span></div>`;
try {
const resp = await fetch(API_BASE + '/stats');
const data = await resp.json();
renderStats(data);
} catch (e) {
content.innerHTML = `<p class="text-red-400 text-sm">Не удалось загрузить статистику</p>`;
}
}
function renderStats(data) {
const content = document.getElementById('stats-content');
const rows = [];
// Meilisearch
if (data.meilisearch) {
const ms = data.meilisearch;
if (ms.status === 'ok') {
rows.push(statRow('🔍 Полнотекстовый индекс', ms.documents?.toLocaleString('ru') + ' сообщений', 'green'));
} else {
rows.push(statRow('🔍 Полнотекстовый индекс', 'Недоступен', 'red'));
}
}
// ChromaDB
if (data.chromadb) {
const ch = data.chromadb;
if (ch.status === 'ok') {
rows.push(statRow('🧠 Векторный индекс', ch.documents?.toLocaleString('ru') + ' векторов', 'green'));
} else {
rows.push(statRow('🧠 Векторный индекс', 'Недоступен', 'red'));
}
}
if (rows.length === 0) {
rows.push(`<p class="text-slate-500 text-sm">Данные недоступны</p>`);
}
content.innerHTML = rows.join('') + `
<div class="mt-4 pt-4 border-t border-slate-700">
<p class="text-xs text-slate-600">Данные из Telegram-группы @snowbikerussia</p>
</div>
`;
}
function statRow(label, value, status) {
const color = status === 'green' ? 'text-emerald-400' : 'text-red-400';
const dot = status === 'green' ? '🟢' : '🔴';
return `
<div class="flex items-center justify-between py-2 border-b border-slate-700 last:border-0">
<span class="text-slate-400">${label}</span>
<span class="${color} font-medium text-right">${dot} ${value}</span>
</div>
`;
}
function closeStats() {
document.getElementById('stats-modal').classList.remove('open');
}
function closeStatsOnBg(e) {
if (e.target === document.getElementById('stats-modal')) closeStats();
}
</script>
</body>
</html>