Files

269 lines
9.7 KiB
JavaScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `<i style="--h:${h}%"></i>`;
}).join("");
return `
<div class="voice-bubble" data-src="${esc(url)}" data-id="${esc(id || "")}">
<button type="button" class="voice-play" aria-label="Воспроизвести">
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>
</button>
<div class="voice-wave" aria-hidden="true">${bars}</div>
<span class="voice-time">0:00</span>
<audio preload="metadata" src="${esc(url)}"></audio>
</div>`;
}
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 `<i style="--h:${h}%"></i>`;
}).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 = '<li class="cat-empty muted">Список пуст — добавьте категорию</li>';
return;
}
list.innerHTML = categories.map((name, i) => `
<li class="cat-item" data-index="${i}">
<span class="cat-order">${i + 1}</span>
<input type="text" class="cat-name" value="${esc(name)}" maxlength="80" aria-label="Категория ${i + 1}" />
<div class="cat-ops">
<button type="button" class="ghost cat-up" title="Выше" ${i === 0 ? "disabled" : ""}>↑</button>
<button type="button" class="ghost cat-down" title="Ниже" ${i === categories.length - 1 ? "disabled" : ""}>↓</button>
<button type="button" class="ghost danger cat-del" title="Удалить">✕</button>
</div>
</li>`).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(`<strong class="cat">${esc(e.category)}</strong>`);
if (e.request_text) bits.push(`<span class="speech">«${esc(e.request_text)}»</span>`);
if (e.bid) bits.push(`<span>bid=${esc(e.bid)} (${esc(e.action || "")})</span>`);
if (e.path) bits.push(`<span>${esc(e.method || "")} ${esc(e.path)}${e.query ? "?" + esc(e.query) : ""}</span>`);
if (e.source_id) bits.push(`<span class="meta">${esc(e.source_id)}</span>`);
if (e.error) bits.push(`<span class="meta">${esc(e.error)}</span>`);
if (e.reason) bits.push(`<span class="meta">${esc(e.reason)}</span>`);
const voice = e.audio_url
? `<div class="card-voice">${voiceBubbleHtml(e.audio_url, e.id || e.request_id || "")}</div>`
: "";
return `<div class="card kind-${esc(e.kind)}">
<div class="card-main">
<span class="kind">${esc(e.kind)}</span>
<span class="ts">${esc(e.ts || "")}</span>
${bits.join(" ")}
</div>
${voice}
</div>`;
}
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 = '<p class="muted">Нет активных вызовов</p>';
} else {
active.innerHTML = data.active.map(c => `
<div class="card active">
<div class="row"><span class="label">Категория</span><strong>${esc(c.category || "")}</strong></div>
<div class="row"><span class="label">Речь</span><span>${esc(c.request_text || "—")}</span></div>
${c.audio_url ? `<div class="card-voice">${voiceBubbleHtml(c.audio_url, c.request_id || "")}</div>` : ""}
<span class="meta">${esc(c.source_id || "")} · ${esc((c.request_id || "").slice(0, 8))}…</span>
</div>`).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);