From 8d815ba09efeb9f2778c4883c598888e2553003c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 03:50:57 +0200 Subject: [PATCH] feat(notes): restore Refine / Shorter / Read / Nextcloud export on every note page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the vanilla refine-bar + output-actions block that every note page had (see public/components/{encounter,dictation,soap,sickvisit, wellvisit,hospital,chart}.html at commit be14578). One shared OutputActions component renders under every generated note's body with: 📋 Copy → navigator.clipboard 🔊 Read → POST /api/text-to-speech (audio/mpeg → Audio playback with stop-on-second-click) ☁️ Export → POST /api/nextcloud/export (uses each page's exportLabel as the filename prefix, exportType in the type field) ✏️ Refine → POST /api/refine — free-text instructions textarea, sourceContext carries the original transcript so the model can reference the source when following orders 📏 Shorter → POST /api/shorten Busy/success/error state shown inline per-component — no native alerts. Refine result replaces the existing output via onUpdate, matching vanilla setOutputText() behavior. Wired into all 7 note pages (Encounter, Dictation, SOAP, Sick Visit, Well Visit, Hospital Course, Chart Review) with per-page exportLabel / exportType matching the vanilla data-label values. --- client/src/components/OutputActions.tsx | 176 ++++++++++++++++++++++++ client/src/pages/ChartReview.tsx | 13 +- client/src/pages/Dictation.tsx | 18 ++- client/src/pages/Encounter.tsx | 15 +- client/src/pages/HospitalCourse.tsx | 13 +- client/src/pages/SickVisit.tsx | 13 +- client/src/pages/Soap.tsx | 13 +- client/src/pages/WellVisit.tsx | 13 +- 8 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 client/src/components/OutputActions.tsx diff --git a/client/src/components/OutputActions.tsx b/client/src/components/OutputActions.tsx new file mode 100644 index 0000000..ad3dd25 --- /dev/null +++ b/client/src/components/OutputActions.tsx @@ -0,0 +1,176 @@ +// ============================================================ +// OutputActions — Copy / Read / Export / Refine / Shorter bar +// that sits under every generated note. Port of the vanilla +// `output-actions` + `refine-bar` blocks (same buttons on every +// note page component). +// +// Copy → navigator.clipboard +// Read → POST /api/text-to-speech (MPEG bytes → Audio) +// Export → POST /api/nextcloud/export +// Refine → POST /api/refine (instructions + sourceContext) +// Shorter→ POST /api/shorten +// +// Each button is independent — if Nextcloud is unconfigured, only +// that one errors; the rest keep working. +// ============================================================ + +import { useRef, useState } from 'react'; +import { api, ApiError } from '@/lib/api'; +import type { RefineOk, ShortenOk } from '@/shared/types'; + +interface Props { + text: string; + // Applied after Refine / Shorter — parent updates its result state. + onUpdate: (newText: string) => void; + // Optional — passed to /api/refine as sourceContext so the model + // can reference the original transcript when following instructions. + sourceContext?: string; + // Nextcloud filename prefix (e.g. 'hpi-encounter', 'soap-note'). + exportLabel: string; + // Nextcloud docType (matches vanilla's data-label / type field). + exportType: string; +} + +export default function OutputActions({ + text, onUpdate, sourceContext, exportLabel, exportType, +}: Props) { + const [instructions, setInstructions] = useState(''); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + const audioRef = useRef(null); + const audioUrlRef = useRef(null); + + async function copy() { + try { + await navigator.clipboard.writeText(text); + setMsg({ kind: 'ok', text: 'Copied' }); + } catch { + setMsg({ kind: 'err', text: 'Copy failed' }); + } + } + + async function read() { + if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to read' }); return; } + // If already playing, stop. + if (audioRef.current && !audioRef.current.paused) { + audioRef.current.pause(); + audioRef.current = null; + if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current); audioUrlRef.current = null; } + return; + } + setBusy('tts'); setMsg(null); + try { + const resp = await fetch('/api/text-to-speech', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!resp.ok) { + const ct = resp.headers.get('content-type') || ''; + const errMsg = ct.includes('json') ? ((await resp.json()).error || 'TTS failed') : 'TTS failed'; + throw new Error(errMsg); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + audioUrlRef.current = url; + const audio = new Audio(url); + audioRef.current = audio; + audio.onended = () => { + URL.revokeObjectURL(url); + if (audioUrlRef.current === url) audioUrlRef.current = null; + if (audioRef.current === audio) audioRef.current = null; + }; + await audio.play(); + } catch (e) { + setMsg({ kind: 'err', text: (e as Error).message }); + } finally { setBusy(null); } + } + + async function exportToNextcloud() { + if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to export' }); return; } + setBusy('export'); setMsg(null); + try { + const filename = exportLabel + '-' + Date.now(); + const data = await api.post<{ success: true; message: string }>('/api/nextcloud/export', { + content: text, filename, type: exportType, + }); + setMsg({ kind: 'ok', text: data.message || 'Exported' }); + } catch (e) { + setMsg({ kind: 'err', text: (e as ApiError).message || 'Nextcloud not connected' }); + } finally { setBusy(null); } + } + + async function refine() { + if (!text.trim()) { setMsg({ kind: 'err', text: 'No document to refine' }); return; } + if (!instructions.trim()) { setMsg({ kind: 'err', text: 'Enter instructions' }); return; } + setBusy('refine'); setMsg(null); + try { + const data = await api.post('/api/refine', { + currentDocument: text, + instructions: instructions.trim(), + sourceContext: sourceContext || undefined, + }); + onUpdate(data.refined); + setInstructions(''); + setMsg({ kind: 'ok', text: 'Refined' }); + } catch (e) { + setMsg({ kind: 'err', text: (e as ApiError).message || 'Refine failed' }); + } finally { setBusy(null); } + } + + async function shorten() { + if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to shorten' }); return; } + setBusy('shorten'); setMsg(null); + try { + const data = await api.post('/api/shorten', { document: text }); + onUpdate(data.shortened); + setMsg({ kind: 'ok', text: 'Shortened' }); + } catch (e) { + setMsg({ kind: 'err', text: (e as ApiError).message || 'Shorten failed' }); + } finally { setBusy(null); } + } + + const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50'; + const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50'; + + return ( +
+
+ + + +
+ +
+