anc
Интерфейс: список встреч слева, заметка справа
- Боковая панель: «Записать», «Добавить аудио» (и перетаскивание файла),
«Новая заметка», список встреч.
- Экран встречи: название правится на месте, шаги «Расшифровать» и
команды ИИ с ходом работы, панель записи с уровнями и текстом
предупреждения для чата Zoom.
- Заметка: «Текст» / «Правка», автосохранение, «Копировать всё»,
«Как текст», копирование раздела, живые галочки задач.
- Сообщения внизу экрана вместо ошибок в тексте страницы.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
996ce52
30 сентября 2026 г.
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 `запись`;
if (m.transcribed_at) return "";
if (m.audio.length) return `не расшифровано`;
return "";
}
function recordBlock(): string {
const r = state.recording;
if (r) {
return `
${r.paused ? "Пауза" : "Идёт запись"}
${elapsed(r.started_at)}
Остановить запись`;
}
return captureSupported
? `Записать`
: `Запись работает в Windows. Здесь — готовые аудиофайлы.`;
}
function draw(): void {
const active = currentId();
root.innerHTML = `
${recordBlock()}
Добавить аудио
Новая заметка
${
meetings.length
? meetings
.map((m) => {
const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
return `
${esc(m.title)}
${esc(meta)} ${badges(m)}
`;
})
.join("")
: `Встреч пока нет.`
}
`;
}
export async function refreshSidebar(): Promise {
meetings = await api.listMeetings();
draw();
}
export async function addAudioFrom(path: string | null): Promise {
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 {
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 {
try {
await api.recorderStop();
await refreshRecording();
await refreshSidebar();
changed();
} catch (e) {
toast(e);
}
}
export async function mountSidebar(el: HTMLElement): Promise {
root = el;
captureSupported = (await api.getSettings()).capture_supported;
await refreshRecording();
await refreshSidebar();
root.addEventListener("click", async (e) => {
const t = (e.target as HTMLElement).closest("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);
});
}