let categories = Array.isArray(window.__INITIAL_CATEGORIES__) ? window.__INITIAL_CATEGORIES__.slice() : []; let lastFeedSig = ""; let lastActiveSig = ""; let activeAudio = null; function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmtTime(sec) { if (!Number.isFinite(sec) || sec < 0) return "0:00"; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${m}:${String(s).padStart(2, "0")}`; } function voiceBubbleHtml(url, id) { const bars = Array.from({ length: 28 }, (_, i) => { const h = 28 + Math.round(42 * Math.abs(Math.sin(i * 0.55 + (id || "").length))); return ``; }).join(""); return `
0:00
`; } function bindVoiceBubbles(root) { root.querySelectorAll(".voice-bubble").forEach(bubble => { if (bubble.dataset.bound) return; bubble.dataset.bound = "1"; const wave = bubble.querySelector(".voice-wave"); if (wave && !wave.children.length) { const id = bubble.dataset.id || ""; wave.innerHTML = Array.from({ length: 28 }, (_, i) => { const h = 28 + Math.round(42 * Math.abs(Math.sin(i * 0.55 + id.length))); return ``; }).join(""); } const audio = bubble.querySelector("audio"); const btn = bubble.querySelector(".voice-play"); const timeEl = bubble.querySelector(".voice-time"); if (!audio || !btn) return; const stopOthers = () => { if (activeAudio && activeAudio !== audio) { activeAudio.pause(); const other = activeAudio.closest(".voice-bubble"); other?.classList.remove("playing"); } }; btn.addEventListener("click", () => { if (audio.paused) { stopOthers(); audio.play().catch(() => {}); bubble.classList.add("playing"); activeAudio = audio; } else { audio.pause(); bubble.classList.remove("playing"); } }); const bars = bubble.querySelectorAll(".voice-wave i"); const paintProgress = (p) => { bubble.style.setProperty("--progress", String(p)); bars.forEach((bar, i) => { bar.classList.toggle("filled", bars.length ? i / bars.length < p : false); }); }; audio.addEventListener("timeupdate", () => { const t = audio.duration && Number.isFinite(audio.duration) ? Math.max(0, audio.duration - audio.currentTime) : audio.currentTime; timeEl.textContent = fmtTime(t); const p = audio.duration ? audio.currentTime / audio.duration : 0; paintProgress(Math.min(1, Math.max(0, p))); }); audio.addEventListener("loadedmetadata", () => { timeEl.textContent = fmtTime(audio.duration); }); audio.addEventListener("ended", () => { bubble.classList.remove("playing"); paintProgress(0); timeEl.textContent = fmtTime(audio.duration); if (activeAudio === audio) activeAudio = null; }); audio.addEventListener("pause", () => { if (!audio.ended) bubble.classList.remove("playing"); }); }); } function renderCategories() { const list = document.getElementById("cat-list"); if (!list) return; if (!categories.length) { list.innerHTML = '
  • Список пуст — добавьте категорию
  • '; return; } list.innerHTML = categories.map((name, i) => `
  • ${i + 1}
  • `).join(""); } function readCategoriesFromDom() { const inputs = document.querySelectorAll("#cat-list .cat-name"); if (!inputs.length) return categories.slice(); return Array.from(inputs).map(el => el.value.trim()).filter(Boolean); } function syncFromInputs() { categories = readCategoriesFromDom(); } document.getElementById("cat-list")?.addEventListener("click", (e) => { const btn = e.target.closest("button"); if (!btn) return; const item = btn.closest(".cat-item"); if (!item) return; syncFromInputs(); const i = Number(item.dataset.index); if (btn.classList.contains("cat-del")) { categories.splice(i, 1); } else if (btn.classList.contains("cat-up") && i > 0) { [categories[i - 1], categories[i]] = [categories[i], categories[i - 1]]; } else if (btn.classList.contains("cat-down") && i < categories.length - 1) { [categories[i + 1], categories[i]] = [categories[i], categories[i + 1]]; } else { return; } renderCategories(); }); document.getElementById("cat-add-btn")?.addEventListener("click", () => { const input = document.getElementById("cat-new"); const name = (input?.value || "").trim(); if (!name) return; syncFromInputs(); if (categories.some(c => c.toLowerCase() === name.toLowerCase())) { document.getElementById("cats-status").textContent = "Уже есть в списке"; return; } categories.push(name); input.value = ""; renderCategories(); document.getElementById("cats-status").textContent = ""; }); document.getElementById("cat-new")?.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); document.getElementById("cat-add-btn")?.click(); } }); document.getElementById("save-cats")?.addEventListener("click", async () => { const status = document.getElementById("cats-status"); syncFromInputs(); status.textContent = "Сохранение…"; try { const r = await fetch("/api/categories", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ categories }), }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || r.statusText); categories = data.categories || []; renderCategories(); status.textContent = "Сохранено"; } catch (e) { status.textContent = "Ошибка: " + e.message; } }); function eventCardHtml(e) { const bits = []; if (e.category) bits.push(`${esc(e.category)}`); if (e.request_text) bits.push(`«${esc(e.request_text)}»`); if (e.bid) bits.push(`bid=${esc(e.bid)} (${esc(e.action || "")})`); if (e.path) bits.push(`${esc(e.method || "")} ${esc(e.path)}${e.query ? "?" + esc(e.query) : ""}`); if (e.source_id) bits.push(`${esc(e.source_id)}`); if (e.error) bits.push(`${esc(e.error)}`); if (e.reason) bits.push(`${esc(e.reason)}`); const voice = e.audio_url ? `
    ${voiceBubbleHtml(e.audio_url, e.id || e.request_id || "")}
    ` : ""; return `
    ${esc(e.kind)} ${esc(e.ts || "")} ${bits.join(" ")}
    ${voice}
    `; } async function refresh() { try { const r = await fetch("/api/events?limit=60"); const data = await r.json(); const active = document.getElementById("active"); const feed = document.getElementById("feed"); if (!active || !feed) return; const activeSig = JSON.stringify(data.active || []); if (activeSig !== lastActiveSig) { lastActiveSig = activeSig; if (!data.active || data.active.length === 0) { active.innerHTML = '

    Нет активных вызовов

    '; } else { active.innerHTML = data.active.map(c => `
    Категория${esc(c.category || "")}
    Речь${esc(c.request_text || "—")}
    ${c.audio_url ? `
    ${voiceBubbleHtml(c.audio_url, c.request_id || "")}
    ` : ""} ${esc(c.source_id || "")} · ${esc((c.request_id || "").slice(0, 8))}…
    `).join(""); bindVoiceBubbles(active); } } const feedSig = JSON.stringify((data.events || []).map(e => [e.id, e.kind, e.audio_url])); if (feedSig !== lastFeedSig) { // Don't wipe DOM while user is listening to an event that still exists const playingId = activeAudio?.closest(".voice-bubble")?.dataset.id || ""; const stillThere = playingId && (data.events || []).some(e => (e.id || e.request_id) === playingId); if (!(activeAudio && !activeAudio.paused && stillThere)) { lastFeedSig = feedSig; feed.innerHTML = (data.events || []).map(eventCardHtml).join(""); bindVoiceBubbles(feed); } } } catch (err) { console.warn(err); } } renderCategories(); bindVoiceBubbles(document); refresh(); setInterval(refresh, 2000);