feat(kon): scaffold hybrid modular workspace
- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers - Minimal Tauri shell (lib.rs + main.rs) with plugin registration - Svelte 5 frontend copied from Ramble v0.2 - All crates compile as empty stubs - App identifier: uk.co.corbel.kon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
669
src/lib/pages/DictationPage.svelte
Normal file
669
src/lib/pages/DictationPage.svelte
Normal file
@@ -0,0 +1,669 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { page, settings, templates, addToHistory, addTask, tasks, saveTasks } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
||||
import { exportTranscript } from "$lib/utils/export.js";
|
||||
import { extractTasks } from "$lib/utils/taskExtractor.js";
|
||||
|
||||
let transcript = $state("");
|
||||
let segments = $state([]);
|
||||
let timerInterval = $state(null);
|
||||
let startTime = $state(0);
|
||||
let chunkId = $state(0);
|
||||
let modelReady = $state(false);
|
||||
let modelLoading = $state(false);
|
||||
let needsDownload = $state(false);
|
||||
let error = $state("");
|
||||
let showExportMenu = $state(false);
|
||||
let transcribing = $state(false);
|
||||
let saved = $state(false);
|
||||
let extractedCount = $state(0);
|
||||
let aiProcessing = $state(false);
|
||||
let aiStatus = $state("");
|
||||
let unlisten = null;
|
||||
let chunkTimeOffset = 0;
|
||||
|
||||
// Cursor-based insertion
|
||||
let textareaEl = $state(null);
|
||||
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
|
||||
|
||||
// Template
|
||||
let activeTemplate = $state("");
|
||||
let showTemplateMenu = $state(false);
|
||||
|
||||
// AudioWorklet state
|
||||
let audioContext = null;
|
||||
let workletNode = null;
|
||||
let mediaStream = null;
|
||||
let pcmBuffer = [];
|
||||
let chunkTimer = null;
|
||||
let allSamples = []; // Accumulate all PCM for audio saving
|
||||
|
||||
// Global hotkey listener
|
||||
let hotkeyHandler = () => toggleRecording();
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen("transcription-result", (event) => {
|
||||
const result = typeof event.payload === "string"
|
||||
? JSON.parse(event.payload)
|
||||
: event.payload;
|
||||
handleResult(result);
|
||||
});
|
||||
|
||||
window.addEventListener("ramble:toggle-recording", hotkeyHandler);
|
||||
|
||||
await checkModelState();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
window.removeEventListener("ramble:toggle-recording", hotkeyHandler);
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
if (page.recording) {
|
||||
page.recording = false;
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function handleResult(result) {
|
||||
if (result.status === "transcription" && result.segments) {
|
||||
const text = result.segments.map((s) => s.text).join(" ").trim();
|
||||
if (text) {
|
||||
if (insertPos >= 0) {
|
||||
// Insert at cursor position
|
||||
const before = transcript.slice(0, insertPos);
|
||||
const after = transcript.slice(insertPos);
|
||||
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
||||
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
|
||||
transcript = before + spaceBefore + text + spaceAfter + after;
|
||||
insertPos += spaceBefore.length + text.length + spaceAfter.length;
|
||||
// Move cursor to end of inserted text
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.selectionStart = insertPos;
|
||||
textareaEl.selectionEnd = insertPos;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Append mode
|
||||
if (transcript && result.chunk_id > 1) {
|
||||
transcript += " " + text;
|
||||
} else if (!transcript) {
|
||||
transcript = text;
|
||||
} else {
|
||||
transcript += " " + text;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset segment timestamps to be absolute
|
||||
const offset = chunkTimeOffset;
|
||||
const adjusted = result.segments.map((s) => ({
|
||||
...s,
|
||||
start: s.start + offset,
|
||||
end: s.end + offset,
|
||||
}));
|
||||
segments = [...segments, ...adjusted];
|
||||
if (result.duration) {
|
||||
chunkTimeOffset += result.duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.recording && result.chunk_id === chunkId) {
|
||||
finaliseTranscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkModelState() {
|
||||
try {
|
||||
if (settings.engine === "parakeet") {
|
||||
const loaded = await invoke("check_parakeet_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_parakeet_model", { name: "ctc-int8" });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
} else {
|
||||
const loaded = await invoke("check_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_model", { size: settings.modelSize.toLowerCase() });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to check model";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModel() {
|
||||
modelLoading = true;
|
||||
page.status = "Loading model...";
|
||||
page.statusColor = "#e8c86e";
|
||||
error = "";
|
||||
|
||||
try {
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("load_parakeet_model", { name: "ctc-int8" });
|
||||
} else {
|
||||
await invoke("load_model", { size: settings.modelSize.toLowerCase() });
|
||||
}
|
||||
modelReady = true;
|
||||
modelLoading = false;
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to load model";
|
||||
modelLoading = false;
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
}
|
||||
}
|
||||
|
||||
function onModelDownloaded() {
|
||||
needsDownload = false;
|
||||
loadModel();
|
||||
}
|
||||
|
||||
async function toggleRecording() {
|
||||
if (page.recording) {
|
||||
await stopRecording();
|
||||
} else {
|
||||
await startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
error = "";
|
||||
saved = false;
|
||||
if (!modelReady) {
|
||||
if (needsDownload) return;
|
||||
await loadModel();
|
||||
if (!modelReady) return;
|
||||
}
|
||||
|
||||
// Capture cursor position for insert mode
|
||||
if (textareaEl && transcript.length > 0) {
|
||||
insertPos = textareaEl.selectionStart ?? transcript.length;
|
||||
} else {
|
||||
insertPos = -1; // Append mode for empty textarea
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
await audioContext.audioWorklet.addModule("/pcm-processor.js");
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
workletNode = new AudioWorkletNode(audioContext, "pcm-processor");
|
||||
|
||||
pcmBuffer = [];
|
||||
chunkId = 0;
|
||||
chunkTimeOffset = 0;
|
||||
|
||||
// Only clear transcript if not in insert mode (fresh recording)
|
||||
if (insertPos === -1) {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
}
|
||||
|
||||
allSamples = [];
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === "pcm") {
|
||||
pcmBuffer = pcmBuffer.concat(e.data.samples);
|
||||
if (settings.saveAudio) {
|
||||
allSamples = allSamples.concat(e.data.samples);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(workletNode);
|
||||
|
||||
startTime = Date.now();
|
||||
page.recording = true;
|
||||
page.status = "Recording...";
|
||||
page.statusColor = "#e87171";
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
chunkTimer = setInterval(sendChunk, 3000);
|
||||
} catch (err) {
|
||||
error = `Microphone access denied: ${err.message}`;
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
page.recording = false;
|
||||
page.status = "Finalising...";
|
||||
page.statusColor = "#e8c86e";
|
||||
|
||||
const waitForTranscription = () => new Promise((resolve) => {
|
||||
const check = () => transcribing ? setTimeout(check, 100) : resolve();
|
||||
check();
|
||||
});
|
||||
await waitForTranscription();
|
||||
await sendChunk();
|
||||
|
||||
cleanup();
|
||||
|
||||
if (chunkId === 0) {
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (workletNode) {
|
||||
workletNode.disconnect();
|
||||
workletNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChunk() {
|
||||
if (pcmBuffer.length < 8000) return;
|
||||
if (transcribing) return;
|
||||
|
||||
chunkId++;
|
||||
const currentChunkId = chunkId;
|
||||
const samples = [...pcmBuffer];
|
||||
pcmBuffer = [];
|
||||
if (samples.length > 4_800_000) {
|
||||
samples.length = 4_800_000;
|
||||
}
|
||||
transcribing = true;
|
||||
|
||||
try {
|
||||
let initialPrompt = "";
|
||||
if (page.activeProfile && page.activeProfile !== "None") {
|
||||
initialPrompt = page.activeProfile;
|
||||
}
|
||||
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("transcribe_pcm_parakeet", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
});
|
||||
} else {
|
||||
await invoke("transcribe_pcm", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
language: settings.language,
|
||||
initialPrompt,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("transcribe_pcm failed:", err);
|
||||
error = typeof err === "string" ? err : err.message || "Transcription failed";
|
||||
if (!page.recording) {
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
}
|
||||
} finally {
|
||||
transcribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finaliseTranscription() {
|
||||
if (transcript.trim()) {
|
||||
if (settings.autoCopy) {
|
||||
navigator.clipboard.writeText(transcript).catch(() => {});
|
||||
}
|
||||
|
||||
// Save audio if enabled — capture path for history replay
|
||||
let audioPath = null;
|
||||
if (settings.saveAudio && allSamples.length > 0) {
|
||||
try {
|
||||
audioPath = await invoke("save_audio", {
|
||||
samples: allSamples,
|
||||
outputFolder: settings.outputFolder || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("save_audio failed:", err);
|
||||
}
|
||||
allSamples = [];
|
||||
}
|
||||
|
||||
const historyId = crypto.randomUUID();
|
||||
addToHistory({
|
||||
id: historyId,
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "live",
|
||||
preview: transcript.slice(0, 120),
|
||||
text: transcript,
|
||||
segments: segments,
|
||||
duration: (Date.now() - startTime) / 1000,
|
||||
language: settings.language,
|
||||
template: activeTemplate || undefined,
|
||||
audioPath,
|
||||
});
|
||||
|
||||
// Extract tasks from transcript
|
||||
const extracted = extractTasks(transcript);
|
||||
extractedCount = extracted.length;
|
||||
for (const item of extracted) {
|
||||
addTask({
|
||||
text: item.text,
|
||||
bucket: "inbox",
|
||||
sourceTranscriptId: historyId,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-open task sidebar if tasks were extracted
|
||||
if (extracted.length > 0) {
|
||||
page.taskSidebarOpen = true;
|
||||
}
|
||||
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
|
||||
}
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
insertPos = -1;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60).toString().padStart(2, "0");
|
||||
const secs = (elapsed % 60).toString().padStart(2, "0");
|
||||
page.timerText = `${mins}:${secs}`;
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
if (transcript) navigator.clipboard.writeText(transcript).catch(() => {});
|
||||
}
|
||||
|
||||
function clearTranscript() {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
page.timerText = "00:00";
|
||||
error = "";
|
||||
saved = false;
|
||||
insertPos = -1;
|
||||
activeTemplate = "";
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
if (!transcript) return;
|
||||
showExportMenu = false;
|
||||
const content = exportTranscript(transcript, segments, format);
|
||||
const ext = format === "vtt" ? "vtt" : format === "srt" ? "srt" : format === "md" ? "md" : "txt";
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `ramble-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function applyTemplate(template) {
|
||||
activeTemplate = template.name;
|
||||
showTemplateMenu = false;
|
||||
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
|
||||
segments = [];
|
||||
// Position cursor at first section body
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
const firstNewline = transcript.indexOf("\n\n") + 2;
|
||||
textareaEl.selectionStart = firstNewline;
|
||||
textareaEl.selectionEnd = firstNewline;
|
||||
textareaEl.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function manualExtractTasks() {
|
||||
if (!transcript.trim() || aiProcessing) return;
|
||||
aiProcessing = true;
|
||||
aiStatus = "Extracting tasks...";
|
||||
try {
|
||||
const extracted = extractTasks(transcript);
|
||||
for (const item of extracted) {
|
||||
addTask({ text: item.text, bucket: "inbox" });
|
||||
}
|
||||
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
|
||||
} catch (err) {
|
||||
aiStatus = typeof err === "string" ? err : "Extraction failed";
|
||||
} finally {
|
||||
aiProcessing = false;
|
||||
setTimeout(() => { aiStatus = ""; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function saveTypedText() {
|
||||
if (!transcript.trim()) return;
|
||||
addToHistory({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "typed",
|
||||
preview: transcript.slice(0, 120),
|
||||
text: transcript,
|
||||
segments: [],
|
||||
duration: 0,
|
||||
language: settings.language,
|
||||
});
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; }, 3000);
|
||||
}
|
||||
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
let wordCount = $derived(
|
||||
transcript.trim() ? transcript.trim().split(/\s+/).length : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full animate-fade-in">
|
||||
{#if needsDownload}
|
||||
<ModelDownloader modelSize={settings.modelSize.toLowerCase()} onComplete={onModelDownloaded} />
|
||||
{:else}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-5 px-7 pt-6 pb-4">
|
||||
<!-- Record button -->
|
||||
<button
|
||||
class="relative flex items-center justify-center w-[56px] h-[56px] min-w-[56px] flex-shrink-0 rounded-full text-white
|
||||
active:scale-[0.93] transition-all duration-200
|
||||
{page.recording
|
||||
? 'bg-danger animate-pulse-warm'
|
||||
: modelLoading
|
||||
? 'bg-warning opacity-60 cursor-wait'
|
||||
: 'bg-accent hover:bg-accent-hover shadow-[0_4px_20px_rgba(232,168,124,0.3)]'}"
|
||||
onclick={toggleRecording}
|
||||
disabled={modelLoading}
|
||||
>
|
||||
{#if page.recording}
|
||||
<span class="w-[18px] h-[18px] rounded-[4px] bg-white"></span>
|
||||
{:else if modelLoading}
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="w-[18px] h-[18px] rounded-full bg-white/90"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Timer + label -->
|
||||
<div class="flex flex-col flex-shrink-0">
|
||||
<span class="text-[28px] font-bold text-text tabular-nums tracking-tight leading-none" style="font-variant-numeric: tabular-nums;">
|
||||
{page.timerText}
|
||||
</span>
|
||||
<span class="text-[11px] text-text-tertiary mt-1">
|
||||
{#if page.recording}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
|
||||
<span class="font-medium text-danger tracking-wide">REC</span>
|
||||
{#if insertPos >= 0}
|
||||
<span class="text-text-tertiary ml-1">inserting at cursor</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if modelLoading}
|
||||
<span class="text-warning">Loading Whisper model...</span>
|
||||
{:else if saved}
|
||||
<span class="text-success animate-fade-in">
|
||||
Saved to history{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
|
||||
</span>
|
||||
{:else}
|
||||
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[10px] text-text-tertiary border border-border-subtle">Ctrl+Shift+R</kbd>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-0.5 flex-shrink-0">
|
||||
<!-- Template selector -->
|
||||
{#if templates.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
|
||||
>Template</button>
|
||||
{#if showTemplateMenu}
|
||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
|
||||
{#each templates as template}
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-[12px] text-text-secondary hover:bg-hover hover:text-text
|
||||
{activeTemplate === template.name ? 'text-accent' : ''}"
|
||||
onclick={() => applyTemplate(template)}
|
||||
>{template.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
|
||||
onclick={manualExtractTasks}
|
||||
disabled={aiProcessing || !transcript.trim()}
|
||||
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
|
||||
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
|
||||
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
|
||||
onclick={saveTypedText}
|
||||
disabled={!transcript.trim()}
|
||||
>Save</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={copyAll}
|
||||
>Copy</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
|
||||
onclick={clearTranscript}
|
||||
>Clear</button>
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
|
||||
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
|
||||
>Export</button>
|
||||
{#if showExportMenu}
|
||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
|
||||
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-[12px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => handleExport(fmt)}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
||||
<!-- Task sidebar toggle -->
|
||||
<button
|
||||
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
|
||||
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
|
||||
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
|
||||
aria-label="Toggle task sidebar"
|
||||
>
|
||||
<svg class="w-4 h-4 {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
{#if taskCount > 0}
|
||||
<span class="absolute -top-0.5 -right-0.5 text-[9px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
|
||||
{taskCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template indicator -->
|
||||
{#if activeTemplate}
|
||||
<div class="px-7 pb-2 animate-fade-in">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
|
||||
<span class="text-[11px] text-accent font-medium">Template: {activeTemplate}</span>
|
||||
<span class="text-[11px] text-text-tertiary">Click a section, then record to fill it</span>
|
||||
<div class="flex-1"></div>
|
||||
<button class="text-[11px] text-text-tertiary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="px-7 pb-2 animate-fade-in">
|
||||
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transcript area -->
|
||||
<div class="flex-1 px-7 pb-3 min-h-0">
|
||||
<Card classes="h-full flex flex-col">
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||
bind:value={transcript}
|
||||
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="flex items-center px-8 pb-4">
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{#if wordCount > 0}
|
||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
{#if aiStatus}
|
||||
<span class="text-[11px] text-accent animate-fade-in mr-3">{aiStatus}</span>
|
||||
{/if}
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user