269 lines
9.7 KiB
JavaScript
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, "<")
|
|
.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 `<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);
|