skillok meeting

Интерфейс как у MacWhisper в стиле Skillok, только светлая тема

- Слева библиотека записей: «Записать», «Добавить аудиофайл», список, «Настройки» внизу; пока идёт запись — метка с таймером вместо кнопки. - Экран записи: сверху название и «Копировать», расшифровка по репликам (время — кто — текст), под ней полоса «Дальше» с Конспектом, Задачами, Ревью и своим запросом. Шаги-«лесенка» убраны. - Ход расшифровки — крупный процент и полоса. - Тёмная тема убрана, окно Tauri всегда светлое. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 30 сентября 2026 г. 43a49bdad7084369e41933f51c5fa3e45dec2040
10 файлов
  • index.html +4 −14
    Показать разницуСкрыть разницу
    9 9 <link rel="stylesheet" href="/src/styles/app.css">
    10 10 </head>
    11 11 <body>
    12 − <header class="header">
    13 − <a class="brand" href="#/">
    14 − <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true">
    15 − <rect width="1024" height="1024" fill="var(--color-accent)"/>
    16 − <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    17 − </svg>
    18 − Skillok Meet
    19 − </a>
    20 − <a id="rec-badge" class="rec-badge" hidden></a>
    21 − <nav class="header-nav" aria-label="Разделы">
    22 − <a href="#/settings" data-nav="settings">Настройки</a>
    23 − </nav>
    24 − </header>
    25 − <main id="app" class="main"></main>
    12 + <div class="shell">
    13 + <aside id="side" class="side" aria-label="Записи"></aside>
    14 + <main id="app" class="main"></main>
    15 + </div>
    26 16 <p id="toast" class="toast" role="status" hidden></p>
    27 17 <script type="module" src="/src/main.ts"></script>
    28 18 </body>
  • Показать разницуСкрыть разницу
    15 15 "title": "Skillok Meet",
    16 16 "width": 1100,
    17 17 "height": 760,
    18 − "minWidth": 720,
    19 − "minHeight": 520
    18 + "minWidth": 760,
    19 + "minHeight": 520,
    20 + "theme": "Light"
    20 21 }
    21 22 ],
    22 23 "security": {
  • src/main.ts +6 −6
    Показать разницуСкрыть разницу
    3 3 import { renderHome } from "./views/home";
    4 4 import { renderMeeting } from "./views/meeting";
    5 5 import { renderSettings } from "./views/settings";
    6 −import { drawHeaderBadge, startHeaderBadge } from "./recording";
    6 +import { refreshSide, startSide } from "./side";
    7 7 import { errorText, files } from "./api";
    8 8 import { esc } from "./dom";
    9 9 import { Scope } from "./scope";
    16 16 scope.dispose();
    17 17 scope = new Scope();
    18 18 const [section, id, tab] = location.hash.replace(/^#\/?/, "").split("/");
    19 − document.querySelector("[data-nav=settings]")?.toggleAttribute("aria-current", section === "settings");
    20 19 main.scrollTop = 0;
    20 + void refreshSide();
    21 21 try {
    22 22 if (section === "m" && id) {
    23 23 await renderMeeting(main, scope, decodeURIComponent(id), tab ? decodeURIComponent(tab) : null);
    27 27 await renderHome(main, scope);
    28 28 }
    29 29 } catch (e) {
    30 − main.innerHTML = `<div class="page"><a class="back small" href="#/">← Все записи</a><p class="error">${esc(errorText(e))}</p></div>`;
    30 + main.innerHTML = `<div class="page"><p class="error">${esc(errorText(e))}</p></div>`;
    31 31 }
    32 32 }
    33 33
    34 34 async function start(): Promise<void> {
    35 35 await refreshRecording();
    36 − startHeaderBadge();
    37 − onChange(drawHeaderBadge);
    38 − drawHeaderBadge();
    36 + startSide();
    37 + // Запись началась, встала на паузу или закончилась — панель слева меняется.
    38 + onChange(() => void refreshSide());
    39 39 // Аудиофайл, брошенный в окно с любого экрана, — новая запись.
    40 40 await files.onDrop(async (paths) => {
    41 41 for (const p of paths) await addAudio(p);
  • src/recording.ts +1 −25
    Показать разницуСкрыть разницу
    1 1 // Запись: панель с таймером, уровнями и кнопками — одна на главной и на
    2 −// экране записи — и метка «идёт запись» в шапке, видная с любого экрана.
    2 +// экране записи. Метка «идёт запись» с любого экрана — в боковой панели (side.ts).
    3 3
    4 4 import { api, elapsed, events } from "./api";
    5 5 import { esc } from "./dom";
    71 71 if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    72 72 });
    73 73 }
    74 −
    75 −/** Метка в шапке: запись идёт, а открыт другой экран. */
    76 −export function drawHeaderBadge(): void {
    77 − const el = document.getElementById("rec-badge");
    78 − if (!el) return;
    79 − const r = state.recording;
    80 − const onItsPage = r && location.hash === `#/m/${encodeURIComponent(r.meeting_id)}`;
    81 − const onHome = r && (location.hash === "" || location.hash === "#/");
    82 − if (!r || onItsPage || onHome) {
    83 − el.hidden = true;
    84 − return;
    85 − }
    86 − el.hidden = false;
    87 − el.setAttribute("href", `#/m/${encodeURIComponent(r.meeting_id)}`);
    88 − el.innerHTML = `<span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"} <span class="mono-num" data-badge-timer>${elapsed(r.started_at)}</span>`;
    89 −}
    90 −
    91 −export function startHeaderBadge(): void {
    92 − window.addEventListener("hashchange", drawHeaderBadge);
    93 − window.setInterval(() => {
    94 − const t = document.querySelector("[data-badge-timer]");
    95 − if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    96 − }, 1000);
    97 −}
  • src/side.ts +81
    Показать разницуСкрыть разницу
    1 +// Боковая панель, как библиотека у MacWhisper: «Записать» сверху, все записи
    2 +// списком, «Настройки» внизу. Видна с любого экрана; пока идёт запись, вместо
    3 +// кнопки — метка с таймером.
    4 +
    5 +import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "./api";
    6 +import { esc } from "./dom";
    7 +import { addAudio, startRecording, state } from "./state";
    8 +
    9 +const side = () => document.getElementById("side")!;
    10 +let meetings: Meeting[] = [];
    11 +let captureSupported = true;
    12 +
    13 +function currentId(): string | null {
    14 + const [section, id] = location.hash.replace(/^#\/?/, "").split("/");
    15 + return section === "m" && id ? decodeURIComponent(id) : null;
    16 +}
    17 +
    18 +function item(m: Meeting, current: string | null): string {
    19 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    20 + const mark = m.recording
    21 + ? `<span class="side-mark is-live"><span class="dot dot-live"></span>запись</span>`
    22 + : m.audio.length && !m.transcribed_at
    23 + ? `<span class="side-mark">не расшифровано</span>`
    24 + : "";
    25 + return `
    26 + <a class="side-item" href="#/m/${encodeURIComponent(m.id)}"${m.id === current ? ` aria-current="page"` : ""}>
    27 + <span class="side-title">${esc(m.title)}</span>
    28 + <span class="side-meta">${esc(meta)}${mark}</span>
    29 + </a>`;
    30 +}
    31 +
    32 +function draw(): void {
    33 + const r = state.recording;
    34 + const current = currentId();
    35 + const top = r
    36 + ? `<a class="side-live" href="#/m/${encodeURIComponent(r.meeting_id)}">
    37 + <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Идёт запись"}
    38 + <span class="mono-num" data-side-timer>${elapsed(r.started_at)}</span>
    39 + </a>`
    40 + : captureSupported
    41 + ? `<button class="ui-btn side-rec" data-side-record>Записать</button>`
    42 + : "";
    43 + side().innerHTML = `
    44 + <a class="brand" href="#/">
    45 + <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true">
    46 + <rect width="1024" height="1024" fill="var(--color-accent)"/>
    47 + <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    48 + </svg>
    49 + Skillok Meet
    50 + </a>
    51 + <div class="side-actions">
    52 + ${top}
    53 + <button class="link-btn" data-side-pick>Добавить аудиофайл</button>
    54 + </div>
    55 + <p class="side-head">Записи${meetings.length ? ` <span class="ui-nav-count">${meetings.length}</span>` : ""}</p>
    56 + <nav class="side-list">
    57 + ${meetings.length ? meetings.map((m) => item(m, current)).join("") : `<p class="side-empty">Здесь появятся ваши записи.</p>`}
    58 + </nav>
    59 + <a class="side-foot" href="#/settings"${location.hash.startsWith("#/settings") ? ` aria-current="page"` : ""}>Настройки</a>`;
    60 + side().querySelector("[data-side-record]")?.addEventListener("click", startRecording);
    61 + side().querySelector("[data-side-pick]")?.addEventListener("click", async () => addAudio(await files.pickAudio()));
    62 +}
    63 +
    64 +/** Перечитать записи и перерисовать панель: после записи, расшифровки, правки. */
    65 +export async function refreshSide(): Promise<void> {
    66 + try {
    67 + const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]);
    68 + meetings = list;
    69 + captureSupported = view.capture_supported;
    70 + } catch {
    71 + // Панель — не главное: список останется прежним, ошибку покажет экран.
    72 + }
    73 + draw();
    74 +}
    75 +
    76 +export function startSide(): void {
    77 + window.setInterval(() => {
    78 + const t = document.querySelector("[data-side-timer]");
    79 + if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    80 + }, 1000);
    81 +}
  • src/styles/app.css +80 −62
    Показать разницуСкрыть разницу
    1 −/* Раскладка: шапка как у skillok.org и одна колонка по центру.
    2 − Путь один: записать → расшифровать → получить результат. */
    1 +/* Раскладка как у MacWhisper: библиотека записей слева, открытая запись справа.
    2 + Стиль — skillok.org: бумага, чернила, терракота, квадратные углы. Тема одна, светлая. */
    3 3
    4 4 html, body { height: 100%; }
    5 −body { display: flex; flex-direction: column; }
    5 +.shell { display: grid; grid-template-columns: 248px 1fr; height: 100%; }
    6 6
    7 −.header {
    8 − flex: none;
    9 − display: flex; align-items: center; gap: 16px;
    10 − height: 52px; padding: 0 24px;
    11 − background: var(--background);
    12 − border-bottom: 1px solid var(--color-border);
    7 +/* ── Боковая панель ── */
    8 +.side {
    9 + display: flex; flex-direction: column; min-height: 0;
    10 + background: var(--color-bg-secondary);
    11 + border-right: 1px solid var(--color-border);
    12 +}
    13 +.brand {
    14 + display: flex; align-items: center; gap: 10px; flex: none;
    15 + height: 52px; padding: 0 16px;
    16 + color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg);
    13 17 }
    14 −.brand { display: flex; align-items: center; gap: 10px; color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg); }
    15 18 .brand:hover { text-decoration: none; }
    16 19 .brand-mark { width: 22px; height: 22px; }
    17 −.header-nav { display: flex; gap: 4px; margin-left: auto; }
    18 −.header-nav a {
    19 − display: inline-flex; align-items: center; height: 32px; padding: 0 10px;
    20 − color: var(--color-text-primary); font-size: var(--text-md);
    20 +.side-actions { display: grid; gap: 6px; justify-items: start; padding: 4px 16px 16px; }
    21 +.side-actions .side-rec { justify-self: stretch; min-height: 40px; }
    22 +.side-live {
    23 + display: flex; align-items: center; gap: 8px; justify-self: stretch;
    24 + min-height: 40px; padding: 0 12px;
    25 + font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text);
    26 + border: 2px solid var(--color-accent); background: var(--color-bg-elevated);
    27 +}
    28 +.side-live:hover { text-decoration: none; background: var(--background); }
    29 +.side-live .mono-num { margin-left: auto; }
    30 +.side-head {
    31 + display: flex; align-items: center; gap: 8px; padding: 8px 16px 6px;
    32 + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
    33 + color: var(--color-text-secondary); border-top: 1px solid var(--color-border);
    21 34 }
    22 −.header-nav a:hover { background: var(--color-bg-hover); text-decoration: none; }
    23 −.header-nav a[aria-current] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    24 −.rec-badge {
    25 − display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px;
    26 − margin-left: auto; font-size: var(--text-sm); color: var(--color-accent-text);
    27 − border: 1px solid var(--color-accent);
    35 +.side-list { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 8px; }
    36 +.side-item {
    37 + display: grid; gap: 2px; padding: 8px 16px;
    38 + color: var(--color-text-primary); box-shadow: inset 2px 0 0 transparent;
    28 39 }
    29 −.rec-badge:hover { text-decoration: none; background: var(--color-bg-hover); }
    30 −.rec-badge[hidden] { display: none; }
    31 −.rec-badge:not([hidden]) + .header-nav { margin-left: 8px; }
    40 +.side-item:hover { background: var(--color-bg-hover); text-decoration: none; }
    41 +.side-item[aria-current] { background: var(--color-bg-elevated); box-shadow: inset 2px 0 0 var(--color-accent); }
    42 +.side-title { font-size: var(--text-md); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    43 +.side-meta { display: flex; flex-wrap: wrap; gap: 0 8px; font-size: var(--text-xs); color: var(--color-text-secondary); }
    44 +.side-mark { display: inline-flex; align-items: center; gap: 6px; color: var(--color-accent-text); }
    45 +.side-empty { padding: 8px 16px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    46 +.side-foot {
    47 + flex: none; display: flex; align-items: center; height: 44px; padding: 0 16px;
    48 + color: var(--color-text-primary); font-size: var(--text-md);
    49 + border-top: 1px solid var(--color-border);
    50 +}
    51 +.side-foot:hover { background: var(--color-bg-hover); text-decoration: none; }
    52 +.side-foot[aria-current] { box-shadow: inset 2px 0 0 var(--color-accent); background: var(--color-bg-elevated); }
    32 53
    33 −.main { flex: 1; min-height: 0; overflow-y: auto; padding: 32px 24px 80px; }
    34 −.home, .meeting, .page { max-width: 760px; margin: 0 auto; display: grid; gap: 24px; }
    54 +.main { min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); }
    55 +.home, .page { max-width: 720px; margin: 0 auto; display: grid; gap: 20px; }
    56 +.meeting { max-width: 860px; margin: 0 auto; display: grid; gap: 20px; }
    35 57
    36 58 /* ── Общее ── */
    37 59 .actions { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
    78 100 .hero h1 { font-size: var(--text-3xl); }
    79 101 .hero-text { max-width: 56ch; color: var(--kv-body); font-size: var(--text-lg); }
    80 102 .hero-btn { min-height: 44px; padding: 0 28px; font-size: var(--text-md); }
    103 +.hero.drop { padding: 24px 32px; gap: 4px; border-style: dashed; border-color: var(--color-border-light); background: transparent; }
    81 104 .hero.is-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    82 105
    83 −.records { display: grid; gap: 12px; }
    84 −.records h2 { font-size: var(--text-lg); }
    85 −.record-list { border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    86 −.record {
    87 − display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status";
    88 − gap: 2px 16px; align-items: center; padding: 14px 18px;
    89 − color: var(--color-text-primary); border-top: 1px solid var(--color-border);
    90 −}
    91 −.record:first-child { border-top: 0; }
    92 −.record:hover { background: var(--color-bg-hover); text-decoration: none; }
    93 −.record-title { grid-area: title; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    94 −.record-meta { grid-area: meta; }
    95 −.status { grid-area: status; font-size: var(--text-sm); color: var(--color-text-secondary); text-align: right; }
    96 −.status-todo { color: var(--color-accent-text); }
    97 −.status-live { color: var(--color-accent-text); font-weight: 700; }
    98 −.status-done { color: var(--color-text-primary); }
    99 −
    100 106 /* ── Запись идёт ── */
    101 107 .rec { display: grid; gap: 14px; justify-items: start; width: 100%; }
    102 108 .rec-state { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    107 113 .meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    108 114
    109 115 /* ── Экран записи ── */
    110 −.meeting-head { display: grid; gap: 4px; }
    116 +.bar {
    117 + position: sticky; top: -32px; z-index: 2;
    118 + display: flex; align-items: center; gap: 16px;
    119 + margin: -32px -32px 0; padding: 20px 32px 12px;
    120 + background: var(--background); border-bottom: 1px solid var(--color-border);
    121 +}
    122 +.bar-title { flex: 1; min-width: 0; display: grid; gap: 2px; }
    123 +.bar-tools { display: flex; align-items: center; gap: 12px; flex: none; }
    111 124 .title-input {
    112 125 width: 100%; padding: 2px 0; margin: 0;
    113 − font: inherit; font-size: var(--text-3xl); font-weight: 700; line-height: 1.25;
    126 + font: inherit; font-size: var(--text-2xl); font-weight: 700; line-height: 1.3;
    114 127 color: var(--color-text-primary); background: transparent;
    115 128 border: 0; border-bottom: 1px solid transparent;
    116 129 }
    117 130 .title-input:hover { border-bottom-color: var(--color-border); }
    118 131 .title-input:focus { outline: none; border-bottom-color: var(--color-accent); }
    119 132
    120 −.stepper { display: flex; margin: 0; padding: 0; list-style: none; }
    121 −.stepper li {
    122 − flex: 1; display: flex; align-items: center; gap: 10px; padding: 10px 0;
    123 − font-size: var(--text-sm); color: var(--color-text-secondary);
    124 − border-bottom: 2px solid var(--color-border);
    125 −}
    126 −.stepper li.is-done { color: var(--color-text-primary); border-bottom-color: var(--kv-ink); }
    127 −.stepper li.is-current { color: var(--color-text-primary); font-weight: 700; border-bottom-color: var(--color-accent); }
    128 −.step-num {
    129 − display: inline-grid; place-items: center; width: 22px; height: 22px;
    130 − font-size: var(--text-xs); border: 1px solid currentColor;
    131 −}
    132 −.stepper li.is-done .step-num { background: var(--kv-ink); color: var(--background); border-color: var(--kv-ink); }
    133 −.stepper li.is-current .step-num { background: var(--color-accent); color: var(--color-on-accent); border-color: var(--color-accent); }
    134 −
    133 +.big-pct { font-size: var(--text-6xl); font-weight: 700; line-height: 1; }
    135 134 .progress { width: 100%; height: 6px; background: var(--color-bg-secondary); overflow: hidden; }
    136 135 .progress > span { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.3s; }
    137 136
    172 171 .path { display: block; padding: 8px 10px; background: var(--color-bg-secondary); word-break: break-all; }
    173 172 .rec-warn { color: var(--color-error); max-width: 60ch; }
    174 173
    174 +/* Расшифровка по репликам: время — кто — текст */
    175 +.tr { display: grid; max-width: 72ch; }
    176 +.tr-row { display: grid; grid-template-columns: 72px 1fr; gap: 16px; padding: 10px 0; border-top: 1px solid var(--kv-line-2); }
    177 +.tr-row:first-child { border-top: 0; padding-top: 0; }
    178 +.tr-time { font-size: var(--text-xs); color: var(--color-text-secondary); padding-top: 3px; }
    179 +.tr-text { display: grid; gap: 2px; }
    180 +.tr-who { font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); }
    181 +.tr-who.is-me { color: var(--color-text-primary); }
    182 +.tr-text p { color: var(--kv-body); line-height: 1.6; }
    183 +
    184 +/* Следующий шаг под расшифровкой */
    185 +.next {
    186 + position: sticky; bottom: -80px; z-index: 1;
    187 + display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
    188 + padding: 12px 24px; border-top: 1px solid var(--color-border); background: var(--color-bg-secondary);
    189 +}
    190 +.next-label { margin-right: 8px; font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-secondary); }
    191 +
    175 192 /* Markdown результата */
    176 193 .md { display: grid; gap: 12px; max-width: 72ch; }
    177 194 .md > * { margin: 0; }
    204 221 }
    205 222 .toast.is-error { background: var(--color-error); color: #fff; }
    206 223
    207 −@media (max-width: 640px) {
    208 − .main { padding: 20px 16px 64px; }
    224 +@media (max-width: 900px) {
    225 + .shell { grid-template-columns: 208px 1fr; }
    226 + .main { padding: 24px 20px 64px; }
    227 + .bar { top: -24px; margin: -24px -20px 0; padding: 16px 20px 10px; }
    209 228 .hero, .card { padding: 24px 20px; }
    210 − .stepper li { font-size: var(--text-xs); gap: 6px; }
    211 229 }
  • src/styles/tokens.css +1 −18
    Показать разницуСкрыть разницу
    76 76 color-scheme: light;
    77 77 }
    78 78
    79 −/* Тёмная палитра — как на skillok.org: чернила и бумага меняются местами. */
    80 −@media (prefers-color-scheme: dark) {
    81 − :root {
    82 − --background: #121212;
    83 − --kv-ink: #F2F2F0;
    84 − --kv-body: #D6D6D2;
    85 − --kv-line: rgba(242, 242, 240, 0.14);
    86 − --kv-line-2: rgba(242, 242, 240, 0.07);
    87 − --color-text-secondary: #A0A09C;
    88 − --color-accent-text: #E08A6B;
    89 − --color-bg-secondary: #1C1C1C;
    90 − --color-bg-elevated: #242424;
    91 − --color-border-light: rgba(242, 242, 240, 0.32);
    92 − --color-bg-hover: rgba(255, 255, 255, 0.06);
    93 − --color-error: #F87171;
    94 − color-scheme: dark;
    95 − }
    96 −}
    79 +/* Тема одна — светлая (решение владельца 01.10.2026): тёмной нет. */
  • src/views/home.ts +12 −46
    Показать разницуСкрыть разницу
    1 −// Главная: записать встречу (или добавить файл) и список записей.
    1 +// Главная: записать встречу или добавить файл. Список записей — в боковой панели.
    2 2
    3 −import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api";
    4 −import { esc } from "../dom";
    5 −import { sections } from "../markdown";
    3 +import { api, files, type SettingsView } from "../api";
    6 4 import { bindRecording, recordingPanel } from "../recording";
    7 5 import type { Scope } from "../scope";
    8 6 import { addAudio, onChange, startRecording, state } from "../state";
    14 12 const pick = `<button class="link-btn" data-pick>выберите файл</button>`;
    15 13 if (!view.capture_supported) {
    16 14 return `
    17 − <section class="hero">
    15 + <section class="hero drop">
    18 16 <h1>Добавьте запись встречи</h1>
    19 − <p class="hero-text">На этой системе запись не поддерживается. Добавьте готовый файл: mp3, m4a, wav и другие.</p>
    20 − <button class="ui-btn" data-pick>Выбрать аудиофайл</button>
    21 − <p class="small muted">Или перетащите файл в окно.</p>
    17 + <p class="hero-text">На этой системе запись не поддерживается. Перетащите сюда аудиофайл — mp3, m4a, wav и другие.</p>
    18 + <button class="ui-btn hero-btn" data-pick>Выбрать файл</button>
    22 19 </section>`;
    23 20 }
    24 21 return `
    25 22 <section class="hero">
    26 23 <h1>Запишите встречу</h1>
    27 − <p class="hero-text">Начните встречу — в Zoom, Google Meet, Телемосте, где угодно — и нажмите «Записать». Запишутся собеседники и вы. Потом расшифруете и получите конспект и задачи.</p>
    24 + <p class="hero-text">Zoom, Google Meet, Телемост — что угодно. Запишутся собеседники и вы, потом — расшифровка, конспект и задачи.</p>
    28 25 <button class="ui-btn hero-btn" data-record>Записать</button>
    29 − <p class="small muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.<br>Уже есть запись? Перетащите аудиофайл в окно или ${pick}.</p>
    30 − </section>`;
    31 −}
    32 −
    33 −function status(m: Meeting, note: string | undefined): string {
    34 − if (m.recording) return `<span class="status status-live">Идёт запись</span>`;
    35 − if (!m.audio.length) return `<span class="status">Нет звука</span>`;
    36 − if (!m.transcribed_at) return `<span class="status status-todo">Нужно расшифровать</span>`;
    37 − const got = sections(note ?? "")
    38 − .map((s) => s.heading)
    39 − .filter((h) => h && h !== "Расшифровка");
    40 − return `<span class="status status-done">${got.length ? esc(got.join(" · ")) : "Расшифровано"}</span>`;
    41 −}
    42 −
    43 −function list(meetings: Meeting[], notes: Map<string, string>): string {
    44 − if (!meetings.length) return "";
    45 − return `
    46 − <section class="records">
    47 − <h2>Записи</h2>
    48 − <div class="record-list">
    49 − ${meetings
    50 − .map((m) => {
    51 − const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    52 − return `
    53 − <a class="record" href="#/m/${encodeURIComponent(m.id)}">
    54 − <span class="record-title">${esc(m.title)}</span>
    55 − <span class="record-meta small muted">${esc(meta)}</span>
    56 − ${status(m, notes.get(m.id))}
    57 − </a>`;
    58 − })
    59 − .join("")}
    60 − </div>
    26 + <p class="small muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p>
    27 + </section>
    28 + <section class="hero drop">
    29 + <p>Уже есть запись? Перетащите аудиофайл сюда или ${pick}.</p>
    30 + <p class="small muted">mp3, m4a, wav, aiff, flac, ogg</p>
    61 31 </section>`;
    62 32 }
    63 33
    64 34 export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
    65 − const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]);
    66 − // Что уже получено по записи — из её заметки; записей немного, читаем все.
    67 − const details = await Promise.all(meetings.filter((m) => m.transcribed_at).map((m) => api.getMeeting(m.id)));
    35 + const view = await api.getSettings();
    68 36 if (!scope.alive) return;
    69 − const notes = new Map(details.map((d) => [d.meeting.id, d.note]));
    70 37
    71 38 const draw = () => {
    72 39 root.innerHTML = `
    77 44 ? ""
    78 45 : `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>`
    79 46 }
    80 − ${list(meetings, notes)}
    81 47 </div>`;
    82 48 root.querySelector("[data-record]")?.addEventListener("click", startRecording);
    83 49 root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
  • src/views/meeting.ts +77 −31
    Показать разницуСкрыть разницу
    1 −// Экран записи: ① Аудио → ② Расшифровка → ③ Результат.
    2 −//
    3 −// На экране только текущий шаг и одна главная кнопка. Результаты — вкладками:
    1 +// Экран записи — рабочее место вокруг записи, как у MacWhisper: сверху
    2 +// название и «Копировать», в центре расшифровка по репликам, под ней следующий
    3 +// шаг. Пока расшифровки нет — одна карточка с главной кнопкой. Результаты — вкладками:
    4 4 // «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
    5 5 // Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
    6 6 // сделать заново или удалить.
    13 13 import { plain, render, sectionBody, sections, toggleTask, upsertSection } from "../markdown";
    14 14 import { bindRecording, recordingPanel } from "../recording";
    15 15 import type { Scope } from "../scope";
    16 +import { refreshSide } from "../side";
    16 17 import { copy, onChange, state, toast } from "../state";
    17 18
    18 19 const TRANSCRIPT = "Расшифровка";
    60 61 return "result";
    61 62 }
    62 63
    63 −function stepper(step: Step): string {
    64 − const order: Step[] = ["audio", "transcribe", "result"];
    65 − const names = { audio: "Аудио", transcribe: "Расшифровка", result: "Результат" };
    66 − const at = order.indexOf(step);
    64 +function progressNote(done: number, total: number): string {
    65 + return `${total ? `Готово ${done} из ${total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.`;
    66 +}
    67 +
    68 +/** Реплика расшифровки, как её пишет ядро: «**00:03:21 Вы:** текст» или «**00:03:21** текст». */
    69 +const LINE = /^\*\*(\d\d:\d\d:\d\d)(?: ([^*]+?):)?\*\*\s*([\s\S]*)$/;
    70 +
    71 +/**
    72 + * Расшифровка — по репликам: время слева, кто говорит и текст справа, как у
    73 + * MacWhisper. Если человек переписал её по-своему — показываем как Markdown.
    74 + */
    75 +function transcriptView(body: string): string {
    76 + const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
    77 + const rows = paras.map((p) => LINE.exec(p));
    78 + if (!rows.length || rows.some((r) => !r)) return `<div class="md">${render(body)}</div>`;
    67 79 return `
    68 − <ol class="stepper">
    69 − ${order
    70 − .map((s, i) => {
    71 − const cls = i < at ? "is-done" : i === at ? "is-current" : "";
    72 − return `<li class="${cls}"><span class="step-num">${i < at ? "✓" : i + 1}</span>${names[s]}</li>`;
    80 + <div class="tr">
    81 + ${rows
    82 + .map((r) => {
    83 + const [, time, who, text] = r!;
    84 + return `
    85 + <div class="tr-row">
    86 + <span class="tr-time mono-num">${esc(time)}</span>
    87 + <div class="tr-text">
    88 + ${who ? `<span class="tr-who${who === "Вы" ? " is-me" : ""}">${esc(who)}</span>` : ""}
    89 + <p>${esc(text)}</p>
    90 + </div>
    91 + </div>`;
    73 92 })
    74 93 .join("")}
    75 − </ol>`;
    94 + </div>`;
    76 95 }
    77 96
    78 97 export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
    117 136 return `
    118 137 <section class="card">
    119 138 <h2>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}</h2>
    139 + <p class="big-pct mono-num" data-pct>${pct}%</p>
    120 140 <div class="progress"><span style="width:${pct}%"></span></div>
    121 − <p class="small muted">${job.total ? `Готово ${job.done} из ${job.total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.</p>
    141 + <p class="small muted" data-progress-note>${progressNote(job.done, job.total)}</p>
    122 142 <div class="actions"><button class="link-btn" data-cancel>Отменить</button></div>
    123 143 </section>`;
    124 144 }
    141 161 </section>`;
    142 162 };
    143 163
    144 − // ── Шаг 3: результат ──────────────────────────────────────────────────────
    164 + // ── Шаг 3: результат ──
    165 + const nextStep = () => {
    166 + const todo = PRESETS.filter((p) => !section(p.heading));
    167 + if (!hasKey) return "";
    168 + return `
    169 + <div class="next">
    170 + <span class="next-label">Дальше</span>
    171 + ${todo.map((p) => `<button class="ui-btn ui-btn-sm" data-make="${esc(p.heading)}" ${job ? "disabled" : ""}>${esc(p.heading)}</button>`).join("")}
    172 + <button class="ui-btn ui-btn-sm" data-tab="${esc(CUSTOM_TAB)}">Свой запрос</button>
    173 + </div>`;
    174 + };
    145 175 const tabContent = () => {
    146 176 const busyHere = job?.kind === "command" && job.tab === tab;
    147 177 const busyElsewhere = job !== null && !busyHere;
    205 235
    206 236 const isTranscript = tab === TRANSCRIPT;
    207 237 const remakeable = !isTranscript && s.heading !== "";
    238 + const body = sectionBody(s.source);
    208 239 return `
    209 240 <div class="pane-tools">
    210 − <button class="link-btn" data-copy>Копировать</button>
    211 − <button class="link-btn" data-copy-plain>Как текст</button>
    212 241 <button class="link-btn" data-edit>Править</button>
    213 242 <span class="spacer"></span>
    214 243 ${
    221 250 }
    222 251 </div>
    223 252 ${errorLine}
    224 − <div class="md pane-body" data-view>${render(sectionBody(s.source))}</div>`;
    253 + <div class="pane-body" data-view>${isTranscript ? transcriptView(body) : `<div class="md">${render(body)}</div>`}</div>
    254 + ${isTranscript ? nextStep() : ""}`;
    225 255 };
    226 256
    227 257 const resultStep = () => `
    243 273 const step = stepOf(d);
    244 274 if (!tabs().includes(tab)) tab = TRANSCRIPT;
    245 275 const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    276 + const copyable = step === "result" && !editing && Boolean(section(tab));
    246 277 root.innerHTML = `
    247 278 <article class="meeting">
    248 − <a class="back small" href="#/">← Все записи</a>
    249 − <header class="meeting-head">
    250 − <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
    251 − <p class="small muted">${esc(meta)}</p>
    279 + <header class="bar">
    280 + <div class="bar-title">
    281 + <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
    282 + <p class="xs muted">${esc(meta)}</p>
    283 + </div>
    284 + ${
    285 + copyable
    286 + ? `<div class="bar-tools">
    287 + <button class="ui-btn ui-btn-sm" data-copy>Копировать</button>
    288 + <button class="link-btn" data-copy-plain>как текст</button>
    289 + </div>`
    290 + : ""
    291 + }
    252 292 </header>
    253 − ${stepper(step)}
    254 293 ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
    255 294 <footer class="meeting-foot">
    256 295 <button class="link-btn" data-open-folder>Открыть папку с файлами</button>
    272 311 d.note = r.note;
    273 312 d.meeting = (await api.getMeeting(id)).meeting;
    274 313 tab = TRANSCRIPT;
    314 + void refreshSide();
    275 315 if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    276 316 } catch (e) {
    277 317 jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    321 361 title.addEventListener("change", async () => {
    322 362 try {
    323 363 d.meeting = await api.renameMeeting(id, title.value);
    364 + void refreshSide();
    324 365 } catch (e) {
    325 366 title.value = d.meeting.title;
    326 367 toast(e);
    347 388 draw();
    348 389 }),
    349 390 );
    350 − root.querySelector<HTMLButtonElement>("[data-make]")?.addEventListener("click", (e) => {
    351 − const heading = (e.currentTarget as HTMLButtonElement).dataset.make!;
    352 − const preset = PRESETS.find((p) => p.heading === heading);
    353 − if (preset) runCommand(preset.command, heading);
    354 − });
    391 + root.querySelectorAll<HTMLButtonElement>("[data-make]").forEach((btn) =>
    392 + btn.addEventListener("click", () => {
    393 + const heading = btn.dataset.make!;
    394 + const preset = PRESETS.find((p) => p.heading === heading);
    395 + if (preset) runCommand(preset.command, heading);
    396 + }),
    397 + );
    355 398 root.querySelector("[data-remake]")?.addEventListener("click", () => {
    356 399 const preset = PRESETS.find((p) => p.heading === tab);
    357 400 // Свой запрос повторяется своим же текстом: он и есть заголовок вкладки.
    437 480 events.progress(({ meeting_id, done, total }) => {
    438 481 if (meeting_id !== id || job?.kind !== "transcribe") return;
    439 482 job = { kind: "transcribe", done, total };
    483 + const pct = total ? Math.round((done / total) * 100) : 0;
    440 484 const bar = root.querySelector<HTMLElement>(".progress > span");
    441 − const note = root.querySelector(".card .small.muted");
    442 − if (bar && total) bar.style.width = `${Math.round((done / total) * 100)}%`;
    443 − if (note) note.textContent = `${total ? `Готово ${done} из ${total} кусков` : "В записи не нашлось речи"}. Можно уйти с экрана — расшифровка продолжится.`;
    485 + const big = root.querySelector("[data-pct]");
    486 + const note = root.querySelector("[data-progress-note]");
    487 + if (bar) bar.style.width = `${pct}%`;
    488 + if (big) big.textContent = `${pct}%`;
    489 + if (note) note.textContent = total ? progressNote(done, total) : "В записи не нашлось речи.";
    444 490 }),
    445 491 );
    446 492 // Запись остановили из шапки или с главной — шаг меняется.
  • Показать разницуСкрыть разницу
    50 50 const s = view.settings;
    51 51 root.innerHTML = `
    52 52 <div class="page">
    53 − <a class="back small" href="#/">← Все записи</a>
    54 53 <div class="page-head"><h1>Настройки</h1></div>
    55 54 ${keyBox(view)}
    56 55