470 lines
14 KiB
Svelte
470 lines
14 KiB
Svelte
<script lang="ts">
|
|
// Center pane: live transcript while recording (FR-TRX-2), or — once a past
|
|
// meeting is selected — raw transcript + editable/rendered notes side by
|
|
// side (FR-NOTE-1/2/6) with export (FR-NOTE-3, T2.6).
|
|
import { recording } from "../stores/recording.svelte";
|
|
import { meetings } from "../stores/meetings.svelte";
|
|
import { settings } from "../stores/settings.svelte";
|
|
import { api, type SpeakerInfo } from "../api";
|
|
import { renderMarkdown } from "../markdown";
|
|
import { save, open } from "@tauri-apps/plugin-dialog";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Heading1,
|
|
Heading2,
|
|
List,
|
|
ListChecks,
|
|
FileDown,
|
|
FileText,
|
|
FolderOutput,
|
|
RefreshCw,
|
|
MessageSquareText,
|
|
NotebookPen,
|
|
Eye,
|
|
Pencil,
|
|
} from "@lucide/svelte";
|
|
|
|
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
|
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
|
}
|
|
|
|
// T8.7/FR-TRX-4: the meeting view shows the language actually used, not
|
|
// just the raw ISO code — falls back to the code itself if it's not in
|
|
// the (curated) catalog, and to "auto-detecting…" before any is known.
|
|
function languageLabel(code: string | null): string {
|
|
if (!code) return "auto-detecting…";
|
|
return settings.languages.find((l) => l.code === code)?.label ?? code;
|
|
}
|
|
|
|
let notesText = $state("");
|
|
// Notes is a single pane: raw markdown ("Editor") or the rendered result
|
|
// ("Preview"), toggled by one button whose label flips to the other mode.
|
|
let notesPreview = $state(false);
|
|
let editorEl: HTMLTextAreaElement | undefined = $state();
|
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let loadedForId: string | null = null;
|
|
|
|
// Sync the editor buffer whenever a different meeting is selected.
|
|
$effect(() => {
|
|
const m = meetings.selected;
|
|
if (m && m.id !== loadedForId) {
|
|
notesText = m.notes_markdown;
|
|
loadedForId = m.id;
|
|
} else if (!m) {
|
|
loadedForId = null;
|
|
}
|
|
});
|
|
|
|
// Recordings default to "Untitled meeting" (T2.2) — this is the only
|
|
// rename affordance, since nothing else in the UI shows the title at all.
|
|
async function onTitleChange(e: Event) {
|
|
const m = meetings.selected;
|
|
const value = (e.target as HTMLInputElement).value.trim();
|
|
if (!m) return;
|
|
if (!value) {
|
|
(e.target as HTMLInputElement).value = m.title; // revert an empty edit
|
|
return;
|
|
}
|
|
if (value !== m.title) await meetings.renameMeeting(m.id, value);
|
|
}
|
|
|
|
function scheduleSave() {
|
|
const id = meetings.selected?.id;
|
|
if (!id) return;
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => meetings.saveNotes(id, notesText), 500);
|
|
}
|
|
|
|
function wrapSelection(before: string, after: string = before) {
|
|
const el = editorEl;
|
|
if (!el) return;
|
|
const { selectionStart: s, selectionEnd: e, value } = el;
|
|
const selected = value.slice(s, e);
|
|
notesText = value.slice(0, s) + before + selected + after + value.slice(e);
|
|
queueMicrotask(() => {
|
|
el.focus();
|
|
el.selectionStart = s + before.length;
|
|
el.selectionEnd = s + before.length + selected.length;
|
|
});
|
|
scheduleSave();
|
|
}
|
|
|
|
function insertLinePrefix(prefix: string) {
|
|
const el = editorEl;
|
|
if (!el) return;
|
|
const { selectionStart: s, value } = el;
|
|
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
|
|
notesText = value.slice(0, lineStart) + prefix + value.slice(lineStart);
|
|
queueMicrotask(() => el.focus());
|
|
scheduleSave();
|
|
}
|
|
|
|
async function exportMd() {
|
|
const m = meetings.selected;
|
|
if (!m) return;
|
|
const path = await save({
|
|
defaultPath: `${m.title}.md`,
|
|
filters: [{ name: "Markdown", extensions: ["md"] }],
|
|
});
|
|
if (path) await api.exportMeeting(m.id, path, "md");
|
|
}
|
|
|
|
async function exportBundle() {
|
|
const m = meetings.selected;
|
|
if (!m) return;
|
|
const dir = await open({ directory: true, defaultPath: m.title });
|
|
if (typeof dir === "string") await api.exportMeeting(m.id, dir, "bundle");
|
|
}
|
|
|
|
async function exportPdf() {
|
|
const m = meetings.selected;
|
|
if (!m) return;
|
|
const path = await save({
|
|
defaultPath: `${m.title}.pdf`,
|
|
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
|
});
|
|
if (path) await api.exportMeeting(m.id, path, "pdf");
|
|
}
|
|
|
|
async function exportDocx() {
|
|
const m = meetings.selected;
|
|
if (!m) return;
|
|
const path = await save({
|
|
defaultPath: `${m.title}.docx`,
|
|
filters: [{ name: "Word document", extensions: ["docx"] }],
|
|
});
|
|
if (path) await api.exportMeeting(m.id, path, "docx");
|
|
}
|
|
|
|
let reprocessModel = $state("");
|
|
// T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default
|
|
// when `language` is omitted) rather than resetting it to auto.
|
|
let reprocessLanguage = $state("");
|
|
let reprocessing = $state(false);
|
|
async function reprocess() {
|
|
const m = meetings.selected;
|
|
if (!m || !reprocessModel) return;
|
|
reprocessing = true;
|
|
try {
|
|
await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined);
|
|
} finally {
|
|
reprocessing = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="wrap">
|
|
{#if meetings.selected}
|
|
{@const m = meetings.selected}
|
|
<input
|
|
class="meeting-title"
|
|
value={m.title}
|
|
onchange={onTitleChange}
|
|
aria-label="Meeting title"
|
|
/>
|
|
<div class="split">
|
|
<div class="pane transcript">
|
|
<h4>
|
|
<MessageSquareText size={14} aria-hidden="true" /> Transcript
|
|
<span class="badge lang" title="Transcription language">{languageLabel(m.language)}</span
|
|
>
|
|
</h4>
|
|
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
|
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
|
|
<div class="reprocess">
|
|
<select bind:value={reprocessModel}>
|
|
<option value="">Re-transcribe with…</option>
|
|
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
|
<option value={mo.id}>{mo.label}</option>
|
|
{/each}
|
|
</select>
|
|
{#if reprocessModelInfo?.multilingual}
|
|
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
|
|
<option value="">Keep current language</option>
|
|
<option value="auto">Auto-detect</option>
|
|
{#each settings.languages as l (l.code)}
|
|
<option value={l.code}>{l.label}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
|
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
|
{reprocessing ? "Re-transcribing…" : "Go"}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{#if m.segments.length === 0}
|
|
<p class="muted">No transcript for this meeting.</p>
|
|
{:else}
|
|
{#each m.segments as s (s.id)}
|
|
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="pane notes">
|
|
<h4><NotebookPen size={14} aria-hidden="true" /> Notes</h4>
|
|
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
|
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
|
|
<Bold size={14} aria-hidden="true" />
|
|
</button>
|
|
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
|
|
<Italic size={14} aria-hidden="true" />
|
|
</button>
|
|
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
|
|
<Heading1 size={14} aria-hidden="true" />
|
|
</button>
|
|
<button onclick={() => insertLinePrefix("## ")} title="Heading 2" aria-label="Heading 2">
|
|
<Heading2 size={14} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
onclick={() => insertLinePrefix("- ")}
|
|
title="Bullet list"
|
|
aria-label="Bullet list"
|
|
>
|
|
<List size={14} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
onclick={() => insertLinePrefix("- [ ] ")}
|
|
title="Checkbox"
|
|
aria-label="Checkbox list item"
|
|
>
|
|
<ListChecks size={14} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
class="toggle"
|
|
onclick={() => (notesPreview = !notesPreview)}
|
|
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
|
|
aria-pressed={notesPreview}
|
|
>
|
|
{#if notesPreview}
|
|
<Pencil size={14} aria-hidden="true" />
|
|
Editor
|
|
{:else}
|
|
<Eye size={14} aria-hidden="true" />
|
|
Preview
|
|
{/if}
|
|
</button>
|
|
<span class="spacer"></span>
|
|
<button onclick={exportMd} title="Export notes as .md">
|
|
<FileText size={13} aria-hidden="true" />
|
|
.md
|
|
</button>
|
|
<button onclick={exportPdf} title="Export notes as .pdf">
|
|
<FileDown size={13} aria-hidden="true" />
|
|
PDF
|
|
</button>
|
|
<button onclick={exportDocx} title="Export notes as .docx">
|
|
<FileDown size={13} aria-hidden="true" />
|
|
Word
|
|
</button>
|
|
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
|
<FolderOutput size={13} aria-hidden="true" />
|
|
Bundle
|
|
</button>
|
|
</div>
|
|
<div class="editor-preview">
|
|
{#if notesPreview}
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
|
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
|
{:else}
|
|
<textarea
|
|
bind:this={editorEl}
|
|
bind:value={notesText}
|
|
oninput={scheduleSave}
|
|
placeholder="Notes…"
|
|
></textarea>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if recording.segments.length === 0}
|
|
<p class="muted pad">Transcript will appear here as you record.</p>
|
|
{:else}
|
|
<div class="pad">
|
|
{#each recording.segments as s (s.id)}
|
|
<p class:interim={s.interim}>
|
|
<strong>{speakerName(s.speaker)}:</strong>
|
|
{s.text}
|
|
</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.wrap {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.meeting-title {
|
|
flex: none;
|
|
border: none;
|
|
background: transparent;
|
|
font-size: 1.05rem;
|
|
font-weight: 600;
|
|
padding: 0.75rem 1rem 0.25rem;
|
|
color: inherit;
|
|
}
|
|
.meeting-title:hover,
|
|
.meeting-title:focus {
|
|
background: var(--border);
|
|
outline: none;
|
|
}
|
|
.pad {
|
|
padding: 1rem;
|
|
max-width: 760px;
|
|
}
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
.interim {
|
|
opacity: 0.55;
|
|
font-style: italic;
|
|
}
|
|
p {
|
|
margin: 0.35rem 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.split {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
.pane {
|
|
overflow: auto;
|
|
padding: 0.75rem 1rem;
|
|
min-width: 0;
|
|
}
|
|
.pane.transcript {
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
.pane h4 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
margin: 0 0 0.6rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--muted);
|
|
}
|
|
/* Transcription language (T8.7, FR-TRX-4) — a quiet pill, not a status
|
|
color, since "which language" isn't a good/bad state to flag. */
|
|
.badge.lang {
|
|
margin-left: auto;
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
text-transform: none;
|
|
letter-spacing: normal;
|
|
color: var(--muted);
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.1rem 0.45rem;
|
|
}
|
|
.reprocess {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.6rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.reprocess select,
|
|
.reprocess button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.3rem 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
:global(.spin) {
|
|
animation: spin 900ms linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
:global(.spin) {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.toolbar button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--fg);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.3rem 0.55rem;
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
transition: background-color 120ms ease-out;
|
|
}
|
|
.toolbar button:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
.toolbar .spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
.editor-preview {
|
|
height: calc(100% - 2.5rem);
|
|
}
|
|
.toolbar .toggle {
|
|
font-weight: 600;
|
|
}
|
|
.toolbar .toggle[aria-pressed="true"] {
|
|
background: var(--bg-hover);
|
|
}
|
|
textarea {
|
|
resize: none;
|
|
width: 100%;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
padding: 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font: inherit;
|
|
line-height: 1.5;
|
|
}
|
|
textarea:focus-visible {
|
|
border-color: var(--accent);
|
|
}
|
|
.preview {
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
overflow: auto;
|
|
padding: 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
}
|
|
.preview :global(h1),
|
|
.preview :global(h2),
|
|
.preview :global(h3) {
|
|
margin: 0.6rem 0 0.3rem;
|
|
}
|
|
</style>
|