From 5494b633ea5e3f6fe1187d29b5fda844a36cbff2 Mon Sep 17 00:00:00 2001 From: Evandro Date: Wed, 10 Jun 2026 21:02:57 -0300 Subject: [PATCH] feat: build editable transcription modal --- src/app.test.ts | 98 +++++++++++++++++++ src/app.ts | 251 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 44 +-------- src/styles.css | 203 ++++++++++++++++++++++++++++----------- 4 files changed, 501 insertions(+), 95 deletions(-) create mode 100644 src/app.test.ts create mode 100644 src/app.ts diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..363be0f --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createTranscriptionApp, type AppSnapshot, type NativeClient } from './app'; + +const baseSnapshot: AppSnapshot = { + recordingStatus: 'idle', + transcript: { + text: 'Texto inicial', + lastMessage: 'Revise o texto antes de inserir.', + }, + settings: { + transcription: { + mode: 'localFirst', + modelId: 'parakeet-tdt-0.6b-v3-q8_0', + language: null, + openaiCompatible: { + endpoint: 'https://api.openai.com/v1/audio/transcriptions', + apiKeyConfigured: false, + model: 'whisper-1', + }, + }, + insertion: { + method: 'clipboardPaste', + restoreClipboard: true, + waylandManualFallback: true, + }, + storage: { + keepAudio: false, + audioFormat: 'wav', + }, + }, + models: [ + { + model: { + id: 'parakeet-tdt-0.6b-v3-q8_0', + displayName: 'Parakeet TDT 0.6B v3 Q8', + repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF', + filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf', + quantization: 'Q8_0', + sizeBytes: 745_121_600, + languages: ['pt'], + recommendedForSimplePc: true, + }, + status: 'notInstalled', + }, + ], +}; + +function createFakeClient(): NativeClient & { calls: string[] } { + const calls: string[] = []; + + return { + calls, + async invoke(command: string): Promise { + calls.push(command); + return baseSnapshot as T; + }, + }; +} + +async function flushPromises(): Promise { + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +describe('createTranscriptionApp', () => { + beforeEach(() => { + document.body.textContent = ''; + }); + + it('renders editable transcript modal with offline model details', async () => { + const mount = document.createElement('main'); + const client = createFakeClient(); + + createTranscriptionApp(mount, client); + await flushPromises(); + + const editor = mount.querySelector('#transcript-editor'); + + expect(editor?.value).toBe('Texto inicial'); + expect(mount.textContent).toContain('Parakeet TDT 0.6B v3 Q8'); + expect(mount.textContent).toContain('Offline primeiro'); + }); + + it('calls native start command when record button is pressed', async () => { + const mount = document.createElement('main'); + const client = createFakeClient(); + + createTranscriptionApp(mount, client); + await flushPromises(); + + const button = mount.querySelector('.primary-button'); + button?.click(); + await flushPromises(); + + expect(client.calls).toContain('get_app_snapshot'); + expect(client.calls).toContain('start_recording'); + }); +}); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..c72767f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,251 @@ +import { invoke as tauriInvoke } from '@tauri-apps/api/core'; + +export type RecordingStatus = 'idle' | 'recording'; + +export interface AppSettings { + transcription: { + mode: 'localFirst' | 'apiOnly'; + modelId: string; + language: string | null; + openaiCompatible: { + endpoint: string; + apiKeyConfigured: boolean; + model: string; + }; + }; + insertion: { + method: 'clipboardPaste' | 'keyTyping' | 'manualCopy'; + restoreClipboard: boolean; + waylandManualFallback: boolean; + }; + storage: { + keepAudio: boolean; + audioFormat: 'wav' | 'ogg' | 'mp3'; + }; +} + +export interface ModelWithStatus { + model: { + id: string; + displayName: string; + repository: string; + filename: string; + quantization: string; + sizeBytes: number; + languages: string[]; + recommendedForSimplePc: boolean; + }; + status: 'notInstalled' | 'ready' | { error: string }; +} + +export interface TranscriptDraft { + text: string; + lastMessage: string | null; +} + +export interface AppSnapshot { + settings: AppSettings; + recordingStatus: RecordingStatus; + transcript: TranscriptDraft; + models: ModelWithStatus[]; +} + +export interface NativeClient { + invoke(command: string, args?: Record): Promise; +} + +const fallbackSnapshot: AppSnapshot = { + recordingStatus: 'idle', + transcript: { + text: '', + lastMessage: 'Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.', + }, + settings: { + transcription: { + mode: 'localFirst', + modelId: 'parakeet-tdt-0.6b-v3-q8_0', + language: null, + openaiCompatible: { + endpoint: 'https://api.openai.com/v1/audio/transcriptions', + apiKeyConfigured: false, + model: 'whisper-1', + }, + }, + insertion: { + method: 'clipboardPaste', + restoreClipboard: true, + waylandManualFallback: true, + }, + storage: { + keepAudio: false, + audioFormat: 'wav', + }, + }, + models: [ + { + model: { + id: 'parakeet-tdt-0.6b-v3-q8_0', + displayName: 'Parakeet TDT 0.6B v3 Q8', + repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF', + filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf', + quantization: 'Q8_0', + sizeBytes: 745_121_600, + languages: ['pt', 'en', 'es', 'fr', 'de'], + recommendedForSimplePc: true, + }, + status: 'notInstalled', + }, + ], +}; + +function createNativeClient(): NativeClient { + return { + invoke: (command: string, args?: Record) => tauriInvoke(command, args), + }; +} + +function element( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function formatBytes(bytes: number): string { + const mib = bytes / 1024 / 1024; + return `${mib.toFixed(0)} MiB`; +} + +async function safeSnapshot(client: NativeClient): Promise { + try { + return await client.invoke('get_app_snapshot'); + } catch { + return fallbackSnapshot; + } +} + +export function createTranscriptionApp( + mount: HTMLElement, + client: NativeClient = createNativeClient(), +): void { + mount.textContent = ''; + + let snapshot = fallbackSnapshot; + + const shell = element('section', 'modal-shell'); + shell.setAttribute('aria-labelledby', 'app-title'); + + const topbar = element('header', 'topbar'); + const titleGroup = element('div', 'title-group'); + const eyebrow = element('p', 'eyebrow', 'Transcript Desktop'); + const title = element('h1', undefined, 'Transcrição'); + title.id = 'app-title'; + const subtitle = element('p', 'subtitle'); + titleGroup.append(eyebrow, title, subtitle); + + const status = element('span', 'status-pill'); + topbar.append(titleGroup, status); + + const editorLabel = element('label', 'editor-label', 'Texto transcrito'); + editorLabel.setAttribute('for', 'transcript-editor'); + + const editor = element('textarea', 'transcript-editor') as HTMLTextAreaElement; + editor.id = 'transcript-editor'; + editor.placeholder = 'A transcrição aparecerá aqui para revisão antes da inserção.'; + editor.rows = 10; + + const message = element('p', 'message'); + message.setAttribute('role', 'status'); + + const actions = element('div', 'actions'); + const recordButton = element('button', 'primary-button', 'Iniciar gravação') as HTMLButtonElement; + const continueButton = element('button', 'secondary-button', '+ Continuar') as HTMLButtonElement; + const copyButton = element('button', 'secondary-button', 'Copiar') as HTMLButtonElement; + const insertButton = element('button', 'secondary-button', 'Inserir no app') as HTMLButtonElement; + const saveButton = element('button', 'ghost-button', 'Salvar texto') as HTMLButtonElement; + actions.append(recordButton, continueButton, copyButton, insertButton, saveButton); + + const modelPanel = element('aside', 'model-panel'); + const modelTitle = element('h2', undefined, 'Modelo offline'); + const modelDetails = element('p'); + modelPanel.append(modelTitle, modelDetails); + + shell.append(topbar, editorLabel, editor, message, actions, modelPanel); + mount.append(shell); + + const render = (next: AppSnapshot): void => { + snapshot = next; + const isRecording = snapshot.recordingStatus === 'recording'; + status.textContent = isRecording ? 'Gravando' : 'Pronto'; + status.dataset.state = snapshot.recordingStatus; + subtitle.textContent = `${snapshot.settings.transcription.mode === 'localFirst' ? 'Offline primeiro' : 'API'} · idioma ${snapshot.settings.transcription.language ?? 'automático'}`; + editor.value = snapshot.transcript.text; + message.textContent = snapshot.transcript.lastMessage ?? 'Revise o texto antes de inserir.'; + recordButton.textContent = isRecording ? 'Parar e transcrever' : 'Iniciar gravação'; + + const firstModel = snapshot.models[0]?.model; + modelDetails.textContent = firstModel + ? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}` + : 'Nenhum modelo offline homologado encontrado.'; + }; + + const refresh = async (): Promise => { + render(await safeSnapshot(client)); + }; + + recordButton.addEventListener('click', () => { + void (async () => { + const command = snapshot.recordingStatus === 'recording' + ? 'stop_recording_and_transcribe' + : 'start_recording'; + render(await client.invoke(command)); + })().catch((error: unknown) => { + message.textContent = error instanceof Error ? error.message : String(error); + }); + }); + + continueButton.addEventListener('click', () => { + void client.invoke('start_recording').then(render).catch((error: unknown) => { + message.textContent = error instanceof Error ? error.message : String(error); + }); + }); + + copyButton.addEventListener('click', () => { + void navigator.clipboard + .writeText(editor.value) + .then(() => { + message.textContent = 'Texto copiado para a área de transferência.'; + }) + .catch(() => { + message.textContent = 'Não foi possível copiar automaticamente. Selecione o texto manualmente.'; + }); + }); + + insertButton.addEventListener('click', () => { + void client + .invoke<{ message: string }>('request_insert_text', { text: editor.value }) + .then((result) => { + message.textContent = result.message; + }) + .catch((error: unknown) => { + message.textContent = error instanceof Error ? error.message : String(error); + }); + }); + + saveButton.addEventListener('click', () => { + const blob = new Blob([editor.value], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'transcricao.txt'; + link.click(); + URL.revokeObjectURL(url); + message.textContent = 'Arquivo de texto preparado para download.'; + }); + + void refresh(); +} diff --git a/src/main.ts b/src/main.ts index 4100334..fb55759 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,48 +1,10 @@ import './styles.css'; +import { createTranscriptionApp } from './app'; -const app = document.querySelector('#app'); +const app = document.querySelector('#app'); if (!app) { throw new Error('Elemento #app não encontrado.'); } -const shell = document.createElement('section'); -shell.className = 'shell'; -shell.setAttribute('aria-labelledby', 'app-title'); - -const hero = document.createElement('header'); -hero.className = 'hero'; - -const eyebrow = document.createElement('p'); -eyebrow.className = 'eyebrow'; -eyebrow.textContent = 'Transcript Desktop'; - -const title = document.createElement('h1'); -title.id = 'app-title'; -title.textContent = 'Transcrição offline para Windows e Linux'; - -const subtitle = document.createElement('p'); -subtitle.className = 'subtitle'; -subtitle.textContent = - 'Estrutura inicial Tauri + Rust preparada para modal editável, CrispASR sidecar e modelo Parakeet GGUF Q8.'; - -hero.append(eyebrow, title, subtitle); - -const card = document.createElement('article'); -card.className = 'card'; -card.setAttribute('aria-label', 'Estado inicial'); - -const statusDot = document.createElement('span'); -statusDot.className = 'status-dot'; -statusDot.setAttribute('aria-hidden', 'true'); - -const cardText = document.createElement('div'); -const cardTitle = document.createElement('h2'); -cardTitle.textContent = 'Pronto para configurar o fluxo'; -const cardDescription = document.createElement('p'); -cardDescription.textContent = 'Os módulos nativos serão conectados nas próximas fatias.'; -cardText.append(cardTitle, cardDescription); - -card.append(statusDot, cardText); -shell.append(hero, card); -app.append(shell); +createTranscriptionApp(app); diff --git a/src/styles.css b/src/styles.css index 6e02d0c..b898c49 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,6 +1,6 @@ :root { - color: #101418; - background: #f5f1ea; + color: #15120f; + background: #f4efe7; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -17,8 +17,8 @@ body { min-width: 320px; min-height: 100vh; background: - radial-gradient(circle at top left, rgba(119, 93, 72, 0.16), transparent 32rem), - #f5f1ea; + radial-gradient(circle at 15% 10%, rgba(122, 79, 56, 0.18), transparent 24rem), + linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%); } button, @@ -28,76 +28,171 @@ select { font: inherit; } -.shell { - width: min(100%, 760px); +button { + border: 0; + cursor: pointer; +} + +button:focus-visible, +textarea:focus-visible { + outline: 3px solid rgba(122, 79, 56, 0.35); + outline-offset: 2px; +} + +#app { min-height: 100vh; - margin: 0 auto; - padding: 3rem 1.25rem; display: grid; - align-content: center; - gap: 1.5rem; + place-items: center; + padding: 1rem; } -.hero { +.modal-shell { + width: min(100%, 680px); display: grid; - gap: 0.75rem; + gap: 1rem; + padding: 1.1rem; + border: 1px solid rgba(21, 18, 15, 0.12); + border-radius: 1.25rem; + background: rgba(255, 252, 247, 0.9); + box-shadow: 0 24px 80px rgba(54, 38, 26, 0.16); } -.eyebrow { - margin: 0; - color: #7a4f38; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; } +.title-group { + display: grid; + gap: 0.35rem; +} + +.eyebrow, h1, h2, p { margin: 0; } +.eyebrow { + color: #7a4f38; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + h1 { - max-width: 12ch; - font-size: clamp(2.5rem, 8vw, 5rem); - line-height: 0.9; - letter-spacing: -0.07em; + color: #15120f; + font-size: clamp(1.9rem, 8vw, 3.4rem); + line-height: 0.95; + letter-spacing: -0.06em; } -.subtitle { - max-width: 42rem; - color: #586069; - font-size: 1.05rem; - line-height: 1.6; -} - -.card { - display: flex; - gap: 1rem; - align-items: flex-start; - padding: 1rem; - border: 1px solid rgba(16, 20, 24, 0.12); - border-radius: 1rem; - background: rgba(255, 255, 255, 0.72); -} - -.card h2 { - font-size: 1rem; -} - -.card p { - margin-top: 0.25rem; - color: #66707a; +.subtitle, +.message, +.model-panel p { + color: #665e55; line-height: 1.5; } -.status-dot { - width: 0.75rem; - height: 0.75rem; - margin-top: 0.25rem; - border-radius: 999px; - background: #1f8f5f; - box-shadow: 0 0 0 0.25rem rgba(31, 143, 95, 0.16); +.status-pill { flex: 0 0 auto; + padding: 0.4rem 0.7rem; + border-radius: 999px; + color: #145a3c; + background: rgba(31, 143, 95, 0.13); + font-size: 0.82rem; + font-weight: 700; +} + +.status-pill[data-state="recording"] { + color: #8a241d; + background: rgba(201, 62, 48, 0.14); +} + +.editor-label { + color: #312923; + font-size: 0.86rem; + font-weight: 700; +} + +.transcript-editor { + width: 100%; + resize: vertical; + min-height: 14rem; + padding: 0.95rem; + border: 1px solid rgba(21, 18, 15, 0.14); + border-radius: 0.9rem; + color: #15120f; + background: #fffdfa; + line-height: 1.55; +} + +.message { + min-height: 1.5rem; + font-size: 0.92rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.primary-button, +.secondary-button, +.ghost-button { + min-height: 2.5rem; + padding: 0.65rem 0.9rem; + border-radius: 0.8rem; + font-weight: 750; +} + +.primary-button { + color: #fffaf5; + background: #15120f; +} + +.secondary-button { + color: #15120f; + background: #eadfd2; +} + +.ghost-button { + color: #6a4a36; + background: transparent; + border: 1px solid rgba(106, 74, 54, 0.22); +} + +.model-panel { + display: grid; + gap: 0.35rem; + padding: 0.85rem; + border-radius: 0.9rem; + background: rgba(234, 223, 210, 0.46); +} + +.model-panel h2 { + font-size: 0.95rem; +} + +@media (max-width: 520px) { + #app { + padding: 0.5rem; + } + + .modal-shell { + border-radius: 1rem; + } + + .topbar { + display: grid; + } + + .actions > button { + flex: 1 1 100%; + } }