skillok meeting
main
Перейти к файлу
Код
meeting srcnav.ts
anc Интерфейс по руководству «Стиль Skillok»; одни часы записи на весь экран По skillok/skillok-develop design/SKILLOK_STYLE.md: - кнопки и поля — --radius-control 2px; поля — --field-bg, 9/11, 13px, фокус терракотой внутрь; неактивная кнопка — 0,5; .ui-danger; мишени 44px под пальцем; - подписи полей — 12px капс с разрядкой; карточки без заливки, отступы 14–16px; вкладки 32px без жирного; моноширинный — только код, числа — tabular-nums; тень — только у сообщения; - значки — icons.ts (SVG 16, контур 1,5) вместо «★ ▶ ❚❚ ✕»; - обращение на «ты» в интерфейсе и сообщениях ядра; сняты пояснения «как пользоваться»; блок проверки звука — .audio-check (не пересекается с галочкой .check). Светлая тема остаётся единственной — решение владельца для Meet (руководство просит обе темы). Шапка и меню показывают время по записанному звуку, как панель записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 955f445 4 октября 2026 г.
nav.ts
// Шапка и меню — как на skillok.org: кнопка ☰ открывает панель слева поверх
// экрана (разделы с иконками, список записей с поиском, «Настройки»), рядом —
// крошки «Skillok Meet / где я». Разметка, классы и поведение панели сняты с
// живого skillok.org (шапка `.hdr`, ящик `.drawer.hdr-nav`, меню `.mn`):
// закрывается крестиком, кликом мимо, Esc и переходом на другой экран.

import { api, files, type Meeting } from "./api";
import { clock, recordedMs } from "./recording";
import { esc } from "./dom";
import { ICONS } from "./icons";
import * as jobs from "./jobs";
import { addAudio, state } from "./state";

let meetings: Meeting[] = [];
let captureSupported = true;
/** Название открытой записи — для крошек. */
let crumb: string | null = null;

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>${clock(recordedMs())}</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="Закрыть меню">${ICONS.close}</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>${clock(recordedMs())}</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 = clock(recordedMs());
    document.querySelectorAll("[data-hdr-timer], [data-drawer-timer]").forEach((el) => (el.textContent = t));
  }, 1000);
}