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 `
`;
}
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);