anc
Ход работы не теряется при уходе с экрана; без «Записать» в шапке и предупреждения
- jobs.ts: расшифровка и ответ ИИ живут, пока открыта программа, а не
экран. Вернулся — видны процент, пришедшие реплики, текст ответа и его
вкладка; кончилось — экран перечитывает запись сам. Раньше экран
показывал «Расшифровать», хотя ядро ещё работало.
- В меню у записи — метка «расшифровка» / «ИИ», пока идёт работа.
- Кнопка «Записать» из шапки убрана (метка идущей записи осталась).
- Блок «Текст предупреждения участникам» убран из настроек, экрана
«Новая запись» и панели записи.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a730a5c
1 октября 2026 г.
nav.ts
// Шапка и меню — как на skillok.org: кнопка ☰ открывает панель слева поверх
// экрана (разделы с иконками, список записей с поиском, «Настройки»), рядом —
// крошки «Skillok Meet / где я». Разметка, классы и поведение панели сняты с
// живого skillok.org (шапка `.hdr`, ящик `.drawer.hdr-nav`, меню `.mn`):
// закрывается крестиком, кликом мимо, Esc и переходом на другой экран.
import { api, elapsed, files, type Meeting } from "./api";
import { esc } from "./dom";
import * as jobs from "./jobs";
import { addAudio, state } from "./state";
let meetings: Meeting[] = [];
let captureSupported = true;
/** Название открытой записи — для крошек. */
let crumb: string | null = null;
const icon = (paths: string) =>
`${paths}`;
// Иконки — Lucide, как у skillok.org.
const ICONS = {
home: icon(''),
mic: icon(''),
file: icon(''),
settings: icon(''),
search: icon(''),
plus: icon(''),
record: icon(''),
};
function route(): { section: string; id: string | null } {
const [section, id] = location.hash.replace(/^#\/?/, "").split("/");
return { section: section ?? "", id: section === "m" && id ? decodeURIComponent(id) : null };
}
function link(href: string, label: string, ic: string, current: boolean, extra = ""): string {
return `
${ic}${label}${extra}`;
}
function drawerBody(): string {
const { section, id } = route();
const r = state.recording;
const rows = meetings
.map((m) => {
const mark = m.recording
? `запись`
: jobs.isBusy(m.id)
? `${jobs.jobOf(m.id)?.kind === "transcribe" ? "расшифровка" : "ИИ"}`
: m.audio.length && !m.transcribed_at
? ``
: "";
return `${esc(m.title)}${mark}`;
})
.join("");
const record = r
? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false,
`${elapsed(r.started_at)}`)
: captureSupported
? link("#/new", "Записать встречу", ICONS.mic, section === "new")
: "";
return `
Skillok Meet
✕
${link("#/", "Главная", ICONS.home, section === "")}
${record}
${ICONS.file}Добавить аудиофайл
Записи
${ICONS.search}
${rows || `Записей пока нет.`}
${link("#/settings", "Настройки", ICONS.settings, section === "settings")}
`;
}
function headerRight(): string {
const r = state.recording;
if (r) {
return `
${r.paused ? "Пауза" : "Запись"}
${elapsed(r.started_at)}`;
}
// Кнопки «Записать» в шапке нет (решение владельца 01.10.2026): запись
// начинается с главной или из меню.
return "";
}
function crumbs(): string {
const { section, id } = route();
const here = section === "settings" ? "Настройки" : section === "new" ? "Новая запись" : id ? crumb ?? "" : "";
return `Skillok Meet${
here ? `/${esc(here)}` : ""
}`;
}
export function draw(): void {
const body = document.getElementById("drawer");
const filter = body?.querySelector("[data-rec-filter]");
const query = filter && !filter.hidden ? filter.value : null;
if (body) body.innerHTML = drawerBody();
document.getElementById("crumbs")!.innerHTML = crumbs();
document.getElementById("hdr-right")!.innerHTML = headerRight();
document.querySelector("[data-nav-pick]")?.addEventListener("click", async () => {
closeAll();
await addAudio(await files.pickAudio());
});
// Поиск остаётся открытым с тем же текстом, если список перерисовался.
if (query !== null) {
const f = document.querySelector("[data-rec-filter]")!;
f.hidden = false;
f.value = query;
applyFilter(query);
}
}
function applyFilter(value: string): void {
const q = value.trim().toLowerCase();
document.querySelectorAll("[data-rec-row]").forEach((row) => {
row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q);
});
}
/** Название открытой записи для крошек; `null` — открыт не экран записи. */
export function setCrumb(title: string | null): void {
crumb = title;
document.getElementById("crumbs")!.innerHTML = crumbs();
}
/** Перечитать записи и перерисовать шапку и меню: после записи, расшифровки, правки. */
export async function refreshNav(): Promise {
try {
const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]);
meetings = list;
captureSupported = view.capture_supported;
} catch {
// Меню — не главное: список останется прежним, ошибку покажет экран.
}
draw();
}
/** Список записей, который уже знает меню, — главной не надо читать его заново. */
export function knownMeetings(): Meeting[] {
return meetings;
}
// ── Поведение ящика — копия скрипта skillok.org ────────────────────────────
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
function close(drawer: HTMLDetailsElement): void {
if (!drawer.open || drawer.classList.contains("is-closing")) return;
if (reduce) {
drawer.open = false;
return;
}
drawer.classList.add("is-closing");
const body = drawer.querySelector(":scope > .drawer-body");
let done = false;
const finish = () => {
if (done) return;
done = true;
drawer.classList.remove("is-closing");
drawer.open = false;
};
body?.addEventListener("animationend", finish, { once: true });
window.setTimeout(finish, 260);
}
function closeAll(): void {
document.querySelectorAll(".drawer[open]").forEach(close);
}
export function startNav(): void {
// Расшифровка началась или кончилась — метка у записи в меню меняется.
jobs.onJobs((_, e) => {
if (e.type === "done") void refreshNav();
else if (e.type === "progress" && !document.querySelector(".drawer[open]")) draw();
});
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const closer = target.closest("[data-drawer-close]");
if (closer) {
const own = closer.closest("details");
if (own) close(own);
return;
}
if (target.closest("[data-rec-search]")) {
const f = document.querySelector("[data-rec-filter]");
if (f) {
f.hidden = !f.hidden;
if (!f.hidden) f.focus();
else applyFilter("");
}
return;
}
const drawer = target.closest(".drawer");
if (!drawer) {
closeAll();
return;
}
const summary = target.closest("summary");
if (summary && summary.parentNode === drawer && drawer.open) {
event.preventDefault();
close(drawer);
return;
}
if (target === drawer) close(drawer);
});
document.addEventListener("input", (event) => {
const t = event.target as HTMLElement;
if (t.matches("[data-rec-filter]")) applyFilter((t as HTMLInputElement).value);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeAll();
});
// Переход на другой экран — ящик закрывается, как у ссылки на сайте.
window.addEventListener("hashchange", closeAll);
window.setInterval(() => {
if (!state.recording) return;
const t = elapsed(state.recording.started_at);
document.querySelectorAll("[data-hdr-timer], [data-drawer-timer]").forEach((el) => (el.textContent = t));
}, 1000);
}