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