787 lines
27 KiB
HTML
787 lines
27 KiB
HTML
<!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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ===================== 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>
|