Files
WhispAssist/src/lib/views/TranscriptNotes.svelte
T
iamdoubz f9b172aff4 Phase 2
2026-06-30 23:18:29 -05:00

231 lines
6.6 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 { api, type SpeakerInfo } from "../api";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { save, open } from "@tauri-apps/plugin-dialog";
// Notes can contain pasted/typed Markdown from anywhere; this webview has an
// IPC bridge to the Rust backend, so unsanitized `{@html}` here would be a
// real local-privilege risk, not just a cosmetic one — sanitize before render.
function renderNotes(markdown: string): string {
return DOMPurify.sanitize(marked.parse(markdown || "") as string);
}
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
return speakers.find((s) => s.label === label)?.display_name ?? label;
}
let notesText = $state("");
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;
}
});
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");
}
</script>
<div class="wrap">
{#if meetings.selected}
{@const m = meetings.selected}
<div class="split">
<div class="pane transcript">
<h4>Transcript</h4>
{#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">
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
<button onclick={() => wrapSelection("**")} title="Bold"><b>B</b></button>
<button onclick={() => wrapSelection("_")} title="Italic"><i>I</i></button>
<button onclick={() => insertLinePrefix("# ")} title="Heading 1">H1</button>
<button onclick={() => insertLinePrefix("## ")} title="Heading 2">H2</button>
<button onclick={() => insertLinePrefix("- ")} title="Bullet list">•</button>
<button onclick={() => insertLinePrefix("- [ ] ")} title="Checkbox">☑</button>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
Export bundle
</button>
</div>
<div class="editor-preview">
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify in renderNotes() -->
<div class="preview">{@html renderNotes(notesText)}</div>
</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%;
}
.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;
height: 100%;
}
.pane {
overflow: auto;
padding: 0.75rem 1rem;
min-width: 0;
}
.pane.transcript {
border-right: 1px solid var(--border);
}
.pane h4 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
.toolbar {
display: flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.toolbar button {
background: transparent;
border: 1px solid var(--border);
color: var(--fg);
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
}
.toolbar .spacer {
flex: 1;
}
.editor-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
height: calc(100% - 2.5rem);
}
textarea {
resize: none;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.preview {
overflow: auto;
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
line-height: 1.5;
}
.preview :global(h1),
.preview :global(h2),
.preview :global(h3) {
margin: 0.6rem 0 0.3rem;
}
</style>