Files
WhispAssist/src/lib/views/TranscriptNotes.svelte
T

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>