anc
Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13)
- Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает
панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском,
Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки
«Skillok Meet / запись». Список записей с поиском — на главной.
- Во время записи один общий уровень вместо «Вы» и «Собеседники».
- Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит,
когда готовы все куски раньше), текст растёт на экране.
- Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос
пишутся на глазах.
- Длинные записи: дорожка не читается в память целиком — нарезка блоками
по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один
раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой
распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ.
- Говорящий в файле без дорожек — «Спикер 1».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3938872
30 сентября 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 { addAudio, startRecording, 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
? `запись`
: 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
? `${ICONS.mic}Записать встречу`
: "";
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)}`;
}
return captureSupported ? `Записать` : "";
}
function crumbs(): string {
const { section, id } = route();
const here = section === "settings" ? "Настройки" : 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.querySelectorAll("[data-nav-record]").forEach((b) => b.addEventListener("click", () => {
closeAll();
void startRecording();
}));
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 {
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);
}