231 lines
6.6 KiB
Svelte
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>
|