skillok meeting
996ce529a8d705e470aea24a7b7fb9a19578f1e4
Перейти к файлу
Код
meeting srcviewssidebar.ts
anc Интерфейс: список встреч слева, заметка справа - Боковая панель: «Записать», «Добавить аудио» (и перетаскивание файла), «Новая заметка», список встреч. - Экран встречи: название правится на месте, шаги «Расшифровать» и команды ИИ с ходом работы, панель записи с уровнями и текстом предупреждения для чата Zoom. - Заметка: «Текст» / «Правка», автосохранение, «Копировать всё», «Как текст», копирование раздела, живые галочки задач. - Сообщения внизу экрана вместо ошибок в тексте страницы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 996ce52 30 сентября 2026 г.
141 строка 5.0 КБ
sidebar.ts
// Боковая панель: три действия и список встреч.
//
//   Записать        — запись Zoom и микрофона, пока не нажмёте «Стоп»
//   Добавить аудио  — готовый файл с диктофона или из облака Zoom
//   Новая заметка   — пустая заметка, текст можно вставить руками

import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "../api";
import { esc } from "../dom";
import { changed, onChange, refreshRecording, state, toast } from "../state";

let root: HTMLElement;
let meetings: Meeting[] = [];
let captureSupported = true;

function currentId(): string | null {
  const m = /^#\/m\/([^/]+)/.exec(location.hash);
  return m ? decodeURIComponent(m[1]) : null;
}

function badges(m: Meeting): string {
  if (m.recording) return `<span class="badge badge-live">запись</span>`;
  if (m.transcribed_at) return "";
  if (m.audio.length) return `<span class="badge">не расшифровано</span>`;
  return "";
}

function recordBlock(): string {
  const r = state.recording;
  if (r) {
    return `
      <a class="rec-now" href="#/m/${encodeURIComponent(r.meeting_id)}">
        <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>
        <span>${r.paused ? "Пауза" : "Идёт запись"}</span>
        <span class="mono-num" data-rec-timer>${elapsed(r.started_at)}</span>
      </a>
      <button class="side-link side-stop" data-stop>Остановить запись</button>`;
  }
  return captureSupported
    ? `<button class="ui-btn side-main" data-record>Записать</button>`
    : `<p class="side-note xs muted">Запись работает в Windows. Здесь — готовые аудиофайлы.</p>`;
}

function draw(): void {
  const active = currentId();
  root.innerHTML = `
    <div class="side-actions">
      ${recordBlock()}
      <button class="side-link" data-add-audio>Добавить аудио</button>
      <button class="side-link" data-new-note>Новая заметка</button>
    </div>
    <nav class="side-list" aria-label="Встречи">
      ${
        meetings.length
          ? meetings
              .map((m) => {
                const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
                return `
            <a class="side-item${m.id === active ? " is-active" : ""}" href="#/m/${encodeURIComponent(m.id)}"${
              m.id === active ? ` aria-current="page"` : ""
            }>
              <span class="side-title">${esc(m.title)}</span>
              <span class="side-meta xs muted">${esc(meta)} ${badges(m)}</span>
            </a>`;
              })
              .join("")
          : `<p class="side-empty small muted">Встреч пока нет.</p>`
      }
    </nav>`;
}

export async function refreshSidebar(): Promise<void> {
  meetings = await api.listMeetings();
  draw();
}

export async function addAudioFrom(path: string | null): Promise<void> {
  if (!path) return;
  if (!files.isAudio(path)) {
    toast("Это не аудиофайл — подойдут mp3, m4a, wav, aiff, flac, ogg");
    return;
  }
  toast("Читаю файл…", "ok");
  try {
    const m = await api.addAudio(path);
    await refreshSidebar();
    location.hash = `#/m/${encodeURIComponent(m.id)}`;
  } catch (e) {
    toast(e);
  }
}

export async function startRecording(): Promise<void> {
  try {
    const m = await api.recorderStart();
    await refreshRecording();
    await refreshSidebar();
    location.hash = `#/m/${encodeURIComponent(m.id)}`;
  } catch (e) {
    toast(e);
  }
}

export async function stopRecording(): Promise<void> {
  try {
    await api.recorderStop();
    await refreshRecording();
    await refreshSidebar();
    changed();
  } catch (e) {
    toast(e);
  }
}

export async function mountSidebar(el: HTMLElement): Promise<void> {
  root = el;
  captureSupported = (await api.getSettings()).capture_supported;
  await refreshRecording();
  await refreshSidebar();
  root.addEventListener("click", async (e) => {
    const t = (e.target as HTMLElement).closest<HTMLElement>("button");
    if (!t) return;
    if (t.matches("[data-record]")) await startRecording();
    else if (t.matches("[data-stop]")) await stopRecording();
    else if (t.matches("[data-add-audio]")) await addAudioFrom(await files.pickAudio());
    else if (t.matches("[data-new-note]")) {
      const m = await api.createMeeting();
      await refreshSidebar();
      location.hash = `#/m/${encodeURIComponent(m.id)}`;
    }
  });
  window.addEventListener("hashchange", draw);
  onChange(draw);
  window.setInterval(() => {
    const t = root.querySelector("[data-rec-timer]");
    if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
  }, 1000);
  // Файл, брошенный в окно, — новая встреча.
  await files.onDrop(async (paths) => {
    for (const p of paths) await addAudioFrom(p);
  });
}