skillok meeting
e581846c7ddcb785b10d499fa25b4c0cde98f88f
Перейти к файлу
Код
meeting srcnav.ts
anc Ход работы не теряется при уходе с экрана; без «Записать» в шапке и предупреждения - jobs.ts: расшифровка и ответ ИИ живут, пока открыта программа, а не экран. Вернулся — видны процент, пришедшие реплики, текст ответа и его вкладка; кончилось — экран перечитывает запись сам. Раньше экран показывал «Расшифровать», хотя ядро ещё работало. - В меню у записи — метка «расшифровка» / «ИИ», пока идёт работа. - Кнопка «Записать» из шапки убрана (метка идущей записи осталась). - Блок «Текст предупреждения участникам» убран из настроек, экрана «Новая запись» и панели записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> a730a5c 1 октября 2026 г.
242 строки 11.5 КБ
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) =>
  `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;

// Иконки — Lucide, как у skillok.org.
const ICONS = {
  home: icon('<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
  mic: icon('<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'),
  file: icon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>'),
  settings: icon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
  search: icon('<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'),
  plus: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'),
  record: icon('<rect x="4" y="4" width="16" height="16"/>'),
};

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 `<a class="mn-link" href="${href}"${current ? ` aria-current="page"` : ""}>
      <span class="mn-icon">${ic}</span><span class="mn-label">${label}</span>${extra}</a>`;
}

function drawerBody(): string {
  const { section, id } = route();
  const r = state.recording;
  const rows = meetings
    .map((m) => {
      const mark = m.recording
        ? `<span class="mn-count" data-strong>запись</span>`
        : jobs.isBusy(m.id)
          ? `<span class="mn-count" data-strong>${jobs.jobOf(m.id)?.kind === "transcribe" ? "расшифровка" : "ИИ"}</span>`
          : m.audio.length && !m.transcribed_at
          ? `<span class="mn-dot" title="Не расшифровано"></span>`
          : "";
      return `<a class="mn-link" data-rec-row href="#/m/${encodeURIComponent(m.id)}" title="${esc(m.title)}"${
        m.id === id ? ` aria-current="page"` : ""
      }><span class="mn-label">${esc(m.title)}</span>${mark}</a>`;
    })
    .join("");
  const record = r
    ? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false,
        `<span class="mn-count" data-strong data-drawer-timer>${elapsed(r.started_at)}</span>`)
    : captureSupported
      ? link("#/new", "Записать встречу", ICONS.mic, section === "new")
      : "";
  return `
    <div class="drawer-top">
      <a class="drawer-home" href="#/">Skillok Meet</a>
      <button type="button" class="drawer-close" data-drawer-close aria-label="Закрыть меню">✕</button>
    </div>
    <aside class="mn">
      <div class="mn-group"><nav class="mn-list" aria-label="Меню">
        ${link("#/", "Главная", ICONS.home, section === "")}
      </nav></div>
      <div class="mn-rule"></div>
      <div class="mn-group"><nav class="mn-list" aria-label="Новая запись">
        ${record}
        <button type="button" class="mn-link" data-nav-pick><span class="mn-icon">${ICONS.file}</span><span class="mn-label">Добавить аудиофайл</span></button>
      </nav></div>
      <div class="mn-rule"></div>
      <div class="mn-group drawer-repos">
        <div class="mn-title drawer-group-row">
          <span>Записи</span>
          <button type="button" class="drawer-icon" data-rec-search title="Найти запись…" aria-label="Найти запись…">${ICONS.search}</button>
        </div>
        <input class="drawer-filter" type="search" data-rec-filter placeholder="Найти запись…" aria-label="Найти запись…" hidden>
        ${rows || `<p class="drawer-empty">Записей пока нет.</p>`}
      </div>
      <div class="mn-rule"></div>
      <div class="mn-group"><nav class="mn-list" aria-label="Программа">
        ${link("#/settings", "Настройки", ICONS.settings, section === "settings")}
      </nav></div>
    </aside>`;
}

function headerRight(): string {
  const r = state.recording;
  if (r) {
    return `<a class="hdr-live" href="#/m/${encodeURIComponent(r.meeting_id)}">
      <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"}
      <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`;
  }
  // Кнопки «Записать» в шапке нет (решение владельца 01.10.2026): запись
  // начинается с главной или из меню.
  return "";
}

function crumbs(): string {
  const { section, id } = route();
  const here = section === "settings" ? "Настройки" : section === "new" ? "Новая запись" : id ? crumb ?? "" : "";
  return `<a class="hdr-crumb${here ? "" : " hdr-crumb-strong"}" href="#/">Skillok Meet</a>${
    here ? `<span class="hdr-slash">/</span><span class="hdr-crumb hdr-crumb-strong" aria-current="page">${esc(here)}</span>` : ""
  }`;
}

export function draw(): void {
  const body = document.getElementById("drawer");
  const filter = body?.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[data-rec-filter]")!;
    f.hidden = false;
    f.value = query;
    applyFilter(query);
  }
}

function applyFilter(value: string): void {
  const q = value.trim().toLowerCase();
  document.querySelectorAll<HTMLElement>("[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<void> {
  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<HTMLDetailsElement>(".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<HTMLInputElement>("[data-rec-filter]");
      if (f) {
        f.hidden = !f.hidden;
        if (!f.hidden) f.focus();
        else applyFilter("");
      }
      return;
    }
    const drawer = target.closest<HTMLDetailsElement>(".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);
}