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:
jake
2026-03-16 20:21:38 +00:00
commit 9926a42b7a
80 changed files with 13328 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,241 @@
<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings, addToHistory } from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import { exportTranscript } from "$lib/utils/export.js";
let fileTranscript = $state("");
let segments = $state([]);
let isDragOver = $state(false);
let progress = $state(0);
let progressText = $state("");
let fileName = $state("");
let error = $state("");
let transcribing = $state(false);
let showExportMenu = $state(false);
let unlistenDrop = null;
let unlistenEnter = null;
let unlistenLeave = null;
onMount(async () => {
// Native Tauri file drop events
unlistenDrop = await listen("tauri://drag-drop", async (event) => {
isDragOver = false;
if (event.payload?.paths?.length > 0) {
await transcribeFiles(event.payload.paths);
}
});
unlistenEnter = await listen("tauri://drag-enter", () => { isDragOver = true; });
unlistenLeave = await listen("tauri://drag-leave", () => { isDragOver = false; });
});
onDestroy(() => {
if (unlistenDrop) unlistenDrop();
if (unlistenEnter) unlistenEnter();
if (unlistenLeave) unlistenLeave();
});
async function handleBrowse() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const paths = await open({
multiple: true,
filters: [{
name: "Audio/Video",
extensions: ["mp3", "wav", "m4a", "mp4", "flac", "ogg", "webm", "mov", "mkv"],
}],
});
if (paths) {
const pathList = Array.isArray(paths) ? paths : [paths];
await transcribeFiles(pathList);
}
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to open file dialog";
}
}
async function transcribeFiles(paths) {
transcribing = true;
error = "";
fileTranscript = "";
segments = [];
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
fileName = path.split(/[\\/]/).pop() || path;
progress = (i / paths.length) * 100;
progressText = `Transcribing ${i + 1}/${paths.length}...`;
try {
const result = await invoke("transcribe_file", {
path,
language: settings.language,
initialPrompt: "",
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
formatMode: settings.formatMode,
});
const text = result.segments.map((s) => s.text).join(" ").trim();
if (paths.length > 1) {
fileTranscript += `--- ${fileName} ---\n${text}\n\n`;
} else {
fileTranscript = text;
}
segments = [...segments, ...result.segments];
addToHistory({
id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"),
source: fileName,
preview: text.slice(0, 120),
text,
segments: result.segments,
duration: result.duration,
language: result.language,
});
} catch (err) {
error = `Failed: ${fileName}${typeof err === "string" ? err : err.message}`;
}
}
progress = 100;
progressText = "Done";
transcribing = false;
setTimeout(() => {
progress = 0;
progressText = "";
fileName = "";
}, 3000);
}
function copyAll() {
if (fileTranscript) navigator.clipboard.writeText(fileTranscript);
}
function handleExport(format) {
if (!fileTranscript) return;
showExportMenu = false;
const content = exportTranscript(fileTranscript, 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-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Title -->
<h2 class="font-display text-[24px] italic text-text px-7 pt-6 pb-4">File Transcription</h2>
<!-- Drop zone -->
<div class="px-7 pb-3">
<Card>
<div
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed m-3
transition-all duration-200
{isDragOver
? 'border-accent bg-accent-subtle scale-[1.01]'
: 'border-border hover:border-text-tertiary'}"
role="region"
>
<div class="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center mb-3">
<svg class="w-5 h-5 text-text-tertiary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
</div>
<p class="text-[13px] text-text-secondary text-center">
Drop audio or video files here
</p>
<p class="text-[11px] text-text-tertiary text-center mt-1.5">
MP3, WAV, M4A, MP4, FLAC, OGG
</p>
</div>
</Card>
</div>
<!-- Browse buttons + progress -->
<div class="flex items-center gap-2 px-7 pb-3">
<button
class="px-4 py-2 rounded-lg text-[13px] font-medium text-white bg-accent hover:bg-accent-hover
shadow-[0_2px_8px_rgba(232,168,124,0.2)] active:scale-[0.97] transition-all duration-150"
onclick={handleBrowse}
disabled={transcribing}
>
Browse Files
</button>
<div class="flex-1"></div>
{#if progressText}
<span class="text-[12px] text-text-secondary animate-fade-in">{progressText}</span>
{/if}
</div>
<!-- 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}
<!-- Progress bar -->
{#if progress > 0}
<div class="px-8 pb-3 animate-fade-in">
<div class="h-[3px] bg-bg-elevated rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full transition-all duration-500 shadow-[0_0_8px_rgba(232,168,124,0.4)]"
style="width: {progress}%"
></div>
</div>
{#if fileName}
<p class="text-[11px] text-text-tertiary mt-1.5">{fileName}</p>
{/if}
</div>
{/if}
<!-- File transcript -->
<div class="flex-1 px-7 pb-3 min-h-0">
<Card classes="h-full flex flex-col">
<textarea
class="font-transcript flex-1 w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary"
placeholder="Transcribed text will appear here..."
bind:value={fileTranscript}
data-no-transition
></textarea>
</Card>
</div>
<!-- Bottom actions -->
<div class="flex gap-0.5 px-7 pb-4">
<button class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text" onclick={copyAll}>Copy</button>
<div class="relative">
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => showExportMenu = !showExportMenu}
>Export</button>
{#if showExportMenu}
<div class="absolute left-0 bottom-full mb-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"], ["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>
</div>
</div>

View File

@@ -0,0 +1,309 @@
<script>
import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { history, saveHistory, deleteFromHistory } from "$lib/stores/page.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core";
import Card from "$lib/components/Card.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS, FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
let searchQuery = $state("");
let selectedItem = $state(null);
let playingId = $state(null);
let audioEl = $state(null);
let currentTime = $state(0);
let duration = $state(0);
let playbackRate = $state(1);
let animFrameId = null;
onDestroy(() => {
stopPlayback();
});
let filtered = $derived(
searchQuery
? history.filter((h) => {
const q = searchQuery.toLowerCase();
return (
(h.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q))
);
})
: history
);
function clearAll() {
history.splice(0);
saveHistory();
selectedItem = null;
stopPlayback();
}
function openItem(item) {
selectedItem = selectedItem === item ? null : item;
}
function copyItem(item) {
navigator.clipboard.writeText(item.text).catch(() => {});
}
function removeItem(item) {
const idx = history.indexOf(item);
if (idx !== -1) {
deleteFromHistory(idx);
if (selectedItem === item) selectedItem = null;
if (playingId === item.id) stopPlayback();
}
}
function renameItem(item) {
const name = prompt("Name this transcript:", item.title || "");
if (name !== null) {
item.title = name.trim();
item.preview = name.trim() ? `${name.trim()}${item.text.slice(0, 80)}` : item.text.slice(0, 120);
saveHistory();
}
}
function togglePlay(item) {
if (playingId === item.id) {
if (audioEl && !audioEl.paused) {
audioEl.pause();
cancelAnimationFrame(animFrameId);
} else if (audioEl) {
audioEl.play();
tickTime();
}
return;
}
stopPlayback();
try {
const src = convertFileSrc(item.audioPath);
const audio = new Audio(src);
audio.playbackRate = playbackRate;
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => { stopPlayback(); };
audio.onerror = () => { stopPlayback(); };
audio.play();
audioEl = audio;
playingId = item.id;
tickTime();
} catch {
stopPlayback();
}
}
function stopPlayback() {
if (audioEl) { audioEl.pause(); audioEl.src = ""; audioEl = null; }
playingId = null;
currentTime = 0;
duration = 0;
cancelAnimationFrame(animFrameId);
}
function tickTime() {
if (audioEl) {
currentTime = audioEl.currentTime;
if (!audioEl.paused) {
animFrameId = requestAnimationFrame(tickTime);
}
}
}
function seekTo(e) {
const val = parseFloat(e.target.value);
if (audioEl) {
audioEl.currentTime = val;
currentTime = val;
}
}
function setSpeed(speed) {
playbackRate = speed;
if (audioEl) audioEl.playbackRate = speed;
}
async function openViewer(item) {
// Store item data for the viewer window
try {
localStorage.setItem("ramble_viewer_item", JSON.stringify(item));
await invoke("open_viewer_window");
} catch {
// Fallback: open in browser tab (dev mode)
localStorage.setItem("ramble_viewer_item", JSON.stringify(item));
window.open("/viewer", "_blank", "width=600,height=700");
}
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Header -->
<div class="flex items-center gap-4 px-7 pt-6 pb-4">
<h2 class="font-display text-[24px] italic text-text">History</h2>
<span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span>
<div class="flex-1"></div>
{#if history.length > 0}
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-tertiary hover:text-danger hover:bg-hover"
onclick={clearAll}
>
Clear All
</button>
{/if}
</div>
<!-- Search -->
<div class="px-7 pb-4">
<Card>
<div class="flex items-center gap-3 px-4 py-2.5">
<svg class="w-4 h-4 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
<input
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
placeholder="Search all transcripts..."
bind:value={searchQuery}
data-no-transition
/>
{#if searchQuery}
<span class="text-[11px] text-text-tertiary mr-2">{filtered.length} results</span>
<button
class="text-[11px] text-text-tertiary hover:text-text"
onclick={() => searchQuery = ""}
>Clear</button>
{/if}
</div>
</Card>
</div>
<!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0">
<Card classes="h-full flex flex-col">
{#if filtered.length === 0}
<div class="flex-1 flex flex-col items-center justify-center gap-3">
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7Z" />
</svg>
</div>
<p class="text-[13px] text-text-tertiary">
{searchQuery ? "No matching transcripts" : "No history yet"}
</p>
<p class="text-[11px] text-text-tertiary">
{searchQuery ? "Try a different search" : "Transcripts auto-save after recording"}
</p>
</div>
{:else}
<div class="flex-1 overflow-y-auto p-3 space-y-1">
{#each filtered as item}
<div
class="w-full text-left p-3 rounded-xl hover:bg-hover cursor-pointer group"
onclick={() => openItem(item)}
onkeydown={(e) => { if (e.key === "Enter") openItem(item); }}
role="button"
tabindex="0"
>
<div class="flex items-center gap-2 mb-1">
<!-- Play button -->
{#if item.audioPath}
<button
class="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0
{playingId === item.id ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-accent hover:bg-accent/20'}"
onclick={(e) => { e.stopPropagation(); togglePlay(item); }}
aria-label={playingId === item.id && audioEl && !audioEl.paused ? "Pause" : "Play"}
>
{#if playingId === item.id && audioEl && !audioEl.paused}
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
{:else}
<svg class="w-3 h-3 ml-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
{/if}
{#if item.title}
<span class="text-[12px] text-text font-medium">{item.title}</span>
<span class="text-[11px] text-text-tertiary">·</span>
{/if}
<span class="text-[11px] text-text-tertiary">{item.date}</span>
<span class="text-[11px] text-text-tertiary">·</span>
<span class="text-[11px] text-text-tertiary">{item.source}</span>
{#if item.duration}
<span class="text-[11px] text-text-tertiary">· {formatDuration(item.duration)}</span>
{/if}
<div class="flex-1"></div>
<!-- Viewer button -->
{#if item.audioPath && item.segments && item.segments.length > 0}
<button
class="text-[11px] text-accent opacity-0 group-hover:opacity-100 hover:text-accent-hover"
onclick={(e) => { e.stopPropagation(); openViewer(item); }}
>Open viewer</button>
{/if}
<button
class="text-[11px] text-text-tertiary opacity-0 group-hover:opacity-100 hover:text-accent"
onclick={(e) => { e.stopPropagation(); renameItem(item); }}
>Rename</button>
<button
class="text-[11px] text-text-tertiary opacity-0 group-hover:opacity-100 hover:text-accent"
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button>
<button
class="text-[11px] text-text-tertiary opacity-0 group-hover:opacity-100 hover:text-danger"
onclick={(e) => { e.stopPropagation(); removeItem(item); }}
>Delete</button>
</div>
<p class="text-[13px] text-text-secondary line-clamp-2 leading-relaxed">
{item.title ? item.text.slice(0, 120) : item.preview}
</p>
{#if selectedItem === item}
<div class="mt-3 pt-3 border-t border-border-subtle animate-slide-up">
<!-- Media player (if audio exists) -->
{#if item.audioPath && playingId === item.id}
<div class="flex items-center gap-3 mb-3 px-1">
<!-- Time -->
<span class="text-[10px] text-text-tertiary tabular-nums w-[80px]">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<!-- Seek bar -->
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={currentTime}
oninput={seekTo}
class="flex-1 accent-accent h-1"
data-no-transition
onclick={(e) => e.stopPropagation()}
/>
<!-- Speed pills -->
<div class="flex gap-0.5">
{#each PLAYBACK_SPEEDS as speed}
<button
class="text-[9px] px-1.5 py-0.5 rounded-full
{playbackRate === speed
? 'bg-accent/15 text-accent font-medium'
: 'text-text-tertiary hover:text-text-secondary'}"
onclick={(e) => { e.stopPropagation(); setSpeed(speed); }}
>{speed}x</button>
{/each}
</div>
</div>
{/if}
<p class="text-[13px] text-text leading-relaxed whitespace-pre-wrap">{item.text}</p>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</Card>
</div>
</div>

View File

@@ -0,0 +1,264 @@
<script>
import { profiles, saveProfiles, templates, saveTemplates, page } from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
let tab = $state("Profiles");
let showNewDialog = $state(false);
let newName = $state("");
// ---- Profiles ----
function createProfile() {
if (!newName.trim()) return;
profiles.push({ name: newName.trim(), words: "", saved: false });
saveProfiles();
newName = "";
showNewDialog = false;
}
function deleteProfile(index) {
if (page.activeProfile === profiles[index].name) {
page.activeProfile = "None";
}
profiles.splice(index, 1);
saveProfiles();
}
function saveProfile(index) {
saveProfiles();
profiles[index].saved = true;
setTimeout(() => { if (profiles[index]) profiles[index].saved = false; }, 2000);
}
function wordCount(words) {
return words.split("\n").filter((w) => w.trim()).length;
}
// ---- Templates ----
function createTemplate() {
if (!newName.trim()) return;
templates.push({ name: newName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
saveTemplates();
newName = "";
showNewDialog = false;
}
function deleteTemplate(index) {
templates.splice(index, 1);
saveTemplates();
}
function saveTemplate(index) {
saveTemplates();
templates[index]._saved = true;
setTimeout(() => { if (templates[index]) templates[index]._saved = false; }, 2000);
}
function handleCreate() {
if (tab === "Profiles") createProfile();
else createTemplate();
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Header -->
<div class="flex items-center gap-4 px-7 pt-6 pb-2">
<h2 class="font-display text-[24px] italic text-text">
{tab === "Profiles" ? "Profiles" : "Templates"}
</h2>
<div class="flex-1"></div>
<button
class="px-4 py-2 rounded-lg text-[13px] font-medium text-white bg-accent hover:bg-accent-hover
shadow-[0_2px_8px_rgba(232,168,124,0.2)] active:scale-[0.97] transition-all duration-150"
onclick={() => showNewDialog = true}
>
+ New {tab === "Profiles" ? "Profile" : "Template"}
</button>
</div>
<!-- Tab switcher -->
<div class="px-7 pb-4">
<SegmentedButton options={["Profiles", "Templates"]} bind:value={tab} />
</div>
<!-- Description -->
<p class="text-[12px] text-text-secondary px-7 pb-5 leading-relaxed max-w-lg">
{#if tab === "Profiles"}
Add custom vocabulary to improve transcription accuracy.
Names, places, and specialist terms that Whisper might misspell.
{:else}
Create structured templates for dictation. Sections become headings
you can click and record into — fill reports, meeting notes, or any repeating format.
{/if}
</p>
<!-- New dialog -->
{#if showNewDialog}
<div class="px-7 pb-4 animate-slide-up">
<Card>
<div class="p-5">
<p class="text-[14px] font-semibold text-text mb-3">
New {tab === "Profiles" ? "Profile" : "Template"}
</p>
<input
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2.5 text-[13px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)] mb-2"
placeholder={tab === "Profiles" ? "e.g. Work, DnD Campaign, Interview..." : "e.g. Meeting Notes, Report, Daily Log..."}
bind:value={newName}
onkeydown={(e) => e.key === "Enter" && handleCreate()}
data-no-transition
/>
<p class="text-[11px] text-text-tertiary mb-4">
{tab === "Profiles"
? "You can add vocabulary after creating the profile."
: "You can edit sections after creating the template."}
</p>
<div class="flex gap-2">
<button
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
active:scale-[0.97] transition-all duration-150"
onclick={handleCreate}
>Create</button>
<button
class="px-4 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => { showNewDialog = false; newName = ""; }}
>Cancel</button>
</div>
</div>
</Card>
</div>
{/if}
<!-- Content -->
<div class="flex-1 px-7 pb-6 space-y-3">
{#if tab === "Profiles"}
<!-- Profile cards -->
{#if profiles.length === 0 && !showNewDialog}
<div class="flex flex-col items-center justify-center py-16 gap-3">
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-5.33 0-8 2.67-8 4v2h16v-2c0-1.33-2.67-4-8-4Z" />
</svg>
</div>
<p class="text-[13px] text-text-tertiary">No profiles yet</p>
<p class="text-[11px] text-text-tertiary">Create one to improve transcription accuracy</p>
</div>
{/if}
{#each profiles as profile, i}
<div class="animate-slide-up">
<Card>
<div class="p-5">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-[15px] font-semibold text-text">{profile.name}</h3>
<span class="text-[11px] text-text-tertiary px-2 py-0.5 rounded-md bg-bg-elevated">
{wordCount(profile.words)} {wordCount(profile.words) === 1 ? 'word' : 'words'}
</span>
<div class="flex-1"></div>
<button
class="px-2.5 py-1 rounded-md text-[11px] text-text-tertiary hover:text-danger hover:bg-hover"
onclick={() => deleteProfile(i)}
>Delete</button>
</div>
<p class="text-[11px] text-text-tertiary mb-3">
One word or phrase per line. These will be prioritised during transcription.
</p>
<textarea
class="w-full h-[140px] bg-bg-elevated border border-border-subtle rounded-xl px-4 py-3
text-[13px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
placeholder="CORBEL&#10;Jake Sames&#10;Northampton&#10;..."
bind:value={profile.words}
data-no-transition
></textarea>
<div class="flex items-center gap-3 mt-3">
<button
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
active:scale-[0.97] transition-all duration-150"
onclick={() => saveProfile(i)}
>Save</button>
{#if profile.saved}
<span class="text-[11px] text-success font-medium animate-fade-in">Saved</span>
{/if}
</div>
</div>
</Card>
</div>
{/each}
{:else}
<!-- Template cards -->
{#if templates.length === 0 && !showNewDialog}
<div class="flex flex-col items-center justify-center py-16 gap-3">
<div class="w-12 h-12 rounded-full bg-bg-elevated flex items-center justify-center">
<svg class="w-6 h-6 text-text-tertiary" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Zm4 18H6V4h7v5h5v11Z" />
</svg>
</div>
<p class="text-[13px] text-text-tertiary">No templates yet</p>
<p class="text-[11px] text-text-tertiary">Create one for structured dictation</p>
</div>
{/if}
{#each templates as template, i}
<div class="animate-slide-up">
<Card>
<div class="p-5">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-[15px] font-semibold text-text">{template.name}</h3>
<span class="text-[11px] text-text-tertiary px-2 py-0.5 rounded-md bg-bg-elevated">
{template.sections.length} sections
</span>
<div class="flex-1"></div>
<button
class="px-2.5 py-1 rounded-md text-[11px] text-text-tertiary hover:text-danger hover:bg-hover"
onclick={() => deleteTemplate(i)}
>Delete</button>
</div>
<p class="text-[11px] text-text-tertiary mb-3">
One section per line. These become headings in your transcript.
</p>
<textarea
class="w-full h-[140px] bg-bg-elevated border border-border-subtle rounded-xl px-4 py-3
text-[13px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
placeholder="Summary&#10;Background&#10;Key Findings&#10;Recommendations"
value={template.sections.join("\n")}
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); }}
data-no-transition
></textarea>
<div class="flex items-center gap-3 mt-3">
<button
class="px-4 py-1.5 rounded-lg text-[12px] font-medium text-white bg-accent hover:bg-accent-hover
active:scale-[0.97] transition-all duration-150"
onclick={() => saveTemplate(i)}
>Save</button>
{#if template._saved}
<span class="text-[11px] text-success font-medium animate-fade-in">Saved</span>
{/if}
</div>
<!-- Preview -->
<div class="mt-3 pt-3 border-t border-border-subtle">
<p class="text-[10px] text-text-tertiary uppercase tracking-wider mb-2">Preview</p>
<div class="text-[12px] text-text-secondary space-y-1">
{#each template.sections as section}
<p class="font-medium text-text"># {section}</p>
{/each}
</div>
</div>
</div>
</Card>
</div>
{/each}
{/if}
</div>
</div>

View File

@@ -0,0 +1,554 @@
<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import Toggle from "$lib/components/Toggle.svelte";
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
import { open } from "@tauri-apps/plugin-dialog";
let engineStatus = $state("Checking...");
let engineOk = $state(false);
let downloadedModels = $state([]);
let downloadingModel = $state("");
let downloadProgress = $state(0);
let unlisten = null;
// Profiles & Templates
let showProfiles = $state(false);
let showTemplates = $state(false);
let editingProfile = $state(-1);
let editingTemplate = $state(-1);
let newProfileName = $state("");
let newTemplateName = $state("");
let showNewProfile = $state(false);
let showNewTemplate = $state(false);
onMount(async () => {
try {
const loaded = await invoke("check_engine");
engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded";
} catch {
engineStatus = "Engine not ready";
}
try {
downloadedModels = await invoke("list_models");
} catch {}
unlisten = await listen("model-download-progress", (event) => {
downloadProgress = event.payload.progress;
});
});
onDestroy(() => {
if (unlisten) unlisten();
});
// Auto-save on any change
$effect(() => {
void settings.engine;
void settings.modelSize;
void settings.language;
void settings.device;
void settings.formatMode;
void settings.removeFillers;
void settings.antiHallucination;
void settings.britishEnglish;
void settings.autoCopy;
void settings.includeTimestamps;
void settings.theme;
void settings.fontSize;
void settings.saveAudio;
void settings.outputFolder;
void settings.globalHotkey;
saveSettings();
});
async function downloadModel(size) {
downloadingModel = size;
downloadProgress = 0;
try {
await invoke("download_model", { size });
downloadedModels = await invoke("list_models");
downloadingModel = "";
} catch {
downloadingModel = "";
}
}
async function loadSelectedModel() {
const size = settings.modelSize.toLowerCase();
engineStatus = "Loading...";
engineOk = false;
try {
await invoke("load_model", { size });
engineOk = true;
engineStatus = `${settings.modelSize} model loaded`;
} catch (err) {
engineOk = false;
engineStatus = typeof err === "string" ? err : "Load failed";
}
}
function isModelDownloaded(size) {
return downloadedModels.includes(size.toLowerCase());
}
const modelDescriptions = {
Tiny: "~75MB · fastest, lower accuracy",
Base: "~150MB · balanced for most use",
Small: "~500MB · noticeably more accurate",
Medium: "~1.5GB · best quality, slower",
};
// --- Profile management ---
function createProfile() {
if (!newProfileName.trim()) return;
const name = newProfileName.trim();
profiles.push({ name, words: "" });
saveProfiles();
addProfileTaskList(name);
newProfileName = "";
showNewProfile = false;
editingProfile = profiles.length - 1;
}
function deleteProfile(index) {
const name = profiles[index].name;
if (page.activeProfile === name) {
page.activeProfile = "None";
}
removeProfileTaskList(name);
profiles.splice(index, 1);
saveProfiles();
editingProfile = -1;
}
function profileWordCount(words) {
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
}
// --- Template management ---
function createTemplate() {
if (!newTemplateName.trim()) return;
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
saveTemplates();
newTemplateName = "";
showNewTemplate = false;
editingTemplate = templates.length - 1;
}
function deleteTemplate(index) {
templates.splice(index, 1);
saveTemplates();
editingTemplate = -1;
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Title -->
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-5">Settings</h2>
<div class="px-7 pb-8 space-y-4">
<!-- Transcription -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-5">Transcription</h3>
<!-- Engine selector -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Engine</p>
<SegmentedButton options={["whisper", "parakeet"]} bind:value={settings.engine} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.engine === "whisper" ? "OpenAI Whisper — 99+ languages, reliable" :
"Nvidia Parakeet — faster on CPU, English-focused, auto-punctuated"}
</p>
</div>
<!-- Format mode -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Format Mode</p>
<SegmentedButton options={["Raw", "Clean", "Smart"]} bind:value={settings.formatMode} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" :
settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" :
"Structured with lists, headings, and sections"}
</p>
</div>
<!-- Whisper model -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Whisper Model</p>
<SegmentedButton options={["Tiny", "Base", "Small", "Medium"]} bind:value={settings.modelSize} />
<p class="text-[11px] text-text-tertiary mt-2">{modelDescriptions[settings.modelSize]}</p>
<!-- Model status per size -->
<div class="flex items-center gap-2 mt-3">
{#if isModelDownloaded(settings.modelSize)}
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
Downloaded
</span>
<button
class="text-[11px] text-text-tertiary hover:text-accent"
onclick={loadSelectedModel}
>Load model</button>
{:else if downloadingModel === settings.modelSize.toLowerCase()}
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
{:else}
<button
class="text-[11px] text-accent hover:text-accent-hover"
onclick={() => downloadModel(settings.modelSize.toLowerCase())}
>Download {settings.modelSize}</button>
{/if}
</div>
</div>
<!-- Compute device -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Compute Device</p>
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-pointer w-[220px]"
bind:value={settings.device}
>
<option value="auto">Auto (CPU)</option>
<option value="cuda">CUDA (NVIDIA GPU)</option>
<option value="cpu">CPU</option>
</select>
<p class="text-[11px] text-text-tertiary mt-2">GPU acceleration requires building with CUDA feature</p>
</div>
<!-- Language -->
<div>
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Language</p>
<div class="flex items-center gap-3">
<select
class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
appearance-none cursor-pointer w-[140px]"
bind:value={settings.language}
>
<option value="en">English</option>
<option value="auto">Auto-detect</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="pl">Polish</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
</select>
{#if settings.language === "en"}
<button
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[11px] border animate-fade-in
{settings.britishEnglish
? 'bg-accent/10 border-accent/30 text-accent font-medium'
: 'bg-bg-input border-border text-text-tertiary hover:text-text-secondary'}"
onclick={() => { settings.britishEnglish = !settings.britishEnglish; }}
title={settings.britishEnglish ? "British English spelling active" : "Click to enable British English spelling"}
>
{#if settings.britishEnglish}
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
British English
</button>
{/if}
</div>
</div>
</div>
</Card>
<!-- Processing -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">Processing</h3>
<div class="space-y-0.5">
<Toggle
bind:checked={settings.removeFillers}
label="Remove filler words"
description="Strips um, uh, like, you know from output"
/>
<Toggle
bind:checked={settings.antiHallucination}
label="Anti-hallucination shield"
description="Detects phantom phrases Whisper generates during silence"
/>
</div>
</div>
</Card>
<!-- AI Assistant (LLM) -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-1">AI Assistant</h3>
<p class="text-[11px] text-text-tertiary mb-4">Local LLM for smart task extraction, transcript cleanup, and formatting. Runs 100% offline.</p>
<div class="bg-bg-input rounded-lg px-3 py-2.5 border border-border-subtle">
<p class="text-[12px] text-text-secondary font-medium mb-1">Coming soon</p>
<p class="text-[11px] text-text-tertiary">AI-powered cleanup and smart extraction are being rebuilt with a faster engine. Task extraction currently uses rule-based matching, which runs automatically after each recording.</p>
</div>
</div>
</Card>
<!-- Profiles & Templates -->
<Card>
<div class="p-5">
<!-- Profiles section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showProfiles = !showProfiles}
>
<svg class="w-3 h-3 text-text-tertiary transition-transform {showProfiles ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5l8 7-8 7z" />
</svg>
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profiles.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
{#if showProfiles}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each profiles as profile, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profileWordCount(profile.words)} words
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingProfile = editingProfile === i ? -1 : i}
>{editingProfile === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteProfile(i)}
>Delete</button>
</div>
{#if editingProfile === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent"
placeholder="One word or phrase per line..."
bind:value={profile.words}
oninput={() => saveProfiles()}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewProfile}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Profile name..."
bind:value={newProfileName}
onkeydown={(e) => e.key === "Enter" && createProfile()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
{/if}
</div>
{/if}
<div class="my-4 h-px bg-border-subtle"></div>
<!-- Templates section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showTemplates = !showTemplates}
>
<svg class="w-3 h-3 text-text-tertiary transition-transform {showTemplates ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5l8 7-8 7z" />
</svg>
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{templates.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
{#if showTemplates}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each templates as template, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{template.sections.length} sections
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
>{editingTemplate === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteTemplate(i)}
>Delete</button>
</div>
{#if editingTemplate === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent"
placeholder="One section per line..."
value={template.sections.join("\n")}
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewTemplate}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Template name..."
bind:value={newTemplateName}
onkeydown={(e) => e.key === "Enter" && createTemplate()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
{/if}
</div>
{/if}
</div>
</Card>
<!-- Output -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">Output</h3>
<div class="space-y-0.5">
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
<Toggle
bind:checked={settings.saveAudio}
label="Save audio recordings"
description="Saves raw audio as .wav files (~2MB per minute). Stored locally."
/>
{#if settings.saveAudio}
<div class="ml-[50px] mt-2 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Output Folder</p>
<div class="flex items-center gap-2">
<div class="flex-1 bg-bg-input rounded-lg px-3 py-1.5 border border-border-subtle">
<p class="text-[11px] text-text-secondary truncate">
{settings.outputFolder || "Default (app data)"}
</p>
</div>
<button
class="text-[11px] text-accent hover:text-accent-hover whitespace-nowrap"
onclick={async () => {
try {
const folder = await open({ directory: true, title: "Select output folder" });
if (folder) { settings.outputFolder = folder; saveSettings(); }
} catch {}
}}
>Change</button>
{#if settings.outputFolder}
<button
class="text-[11px] text-text-tertiary hover:text-text-secondary whitespace-nowrap"
onclick={() => { settings.outputFolder = ""; saveSettings(); }}
>Reset</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
</Card>
<!-- Hotkey -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-1">Global Hotkey</h3>
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
<HotkeyRecorder />
</div>
</Card>
<!-- Appearance -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-5">Appearance</h3>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
<SegmentedButton options={["Dark", "Light", "System"]} bind:value={settings.theme} />
</div>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Font Size <span class="font-normal text-text-secondary ml-1">{settings.fontSize}px</span>
</p>
<input
type="range" min="10" max="24" step="1"
bind:value={settings.fontSize}
class="w-[200px] accent-accent"
data-no-transition
/>
</div>
</div>
</Card>
<!-- About -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">About</h3>
<!-- Engine status -->
<div class="flex items-center gap-2 mb-4">
<span class="w-[7px] h-[7px] rounded-full {engineOk ? 'bg-success' : 'bg-warning'}"></span>
<span class="text-[12px] text-text-secondary">{engineStatus}</span>
</div>
<div class="space-y-1.5">
{#each [
"100% offline — all processing on your machine",
"No Python required — compiled Whisper engine",
"No cloud — audio never leaves your computer",
"No accounts — no sign-up, no tracking",
"No telemetry — zero data collection",
] as item}
<div class="flex items-start gap-2">
<svg class="w-3.5 h-3.5 text-success mt-0.5 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17Z" />
</svg>
<p class="text-[11px] text-text-secondary">{item}</p>
</div>
{/each}
</div>
<p class="text-[11px] text-text-tertiary mt-5">Ramble v0.2 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
</div>
</Card>
</div>
</div>

View File

@@ -0,0 +1,512 @@
<script>
import { invoke } from "@tauri-apps/api/core";
import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
taskLists, addTaskList, renameTaskList, deleteTaskList,
} from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import { formatTimestamp } from "$lib/utils/time.js";
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
let activeBucket = $state("all");
let activeListId = $state("all");
let showCompleted = $state(false);
let quickInput = $state("");
let searchQuery = $state("");
let sidebarCollapsed = $state(false);
let showNewList = $state(false);
let newListName = $state("");
let sortMode = $state("date");
let showSortMenu = $state(false);
let contextMenuListId = $state(null);
let editingListId = $state(null);
let editingName = $state("");
const buckets = [
{ id: "all", label: "All", icon: "M4 6h16M4 12h16M4 18h16" },
{ id: "inbox", label: "Inbox", icon: "M20 12V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6m16 0v6a2 2 0 0 0-2 2H6a2 2 0 0 0-2-2v-6m16 0H4" },
{ id: "today", label: "Today", icon: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm-1-13v5l4.28 2.54.72-1.21-3.5-2.08V7h-1.5Z" },
{ id: "soon", label: "Soon", icon: "M17 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2ZM7 7h10M7 11h10M7 15h5" },
{ id: "later", label: "Later", icon: "M5 3v18l7-3 7 3V3H5Z" },
];
let filteredTasks = $derived.by(() => {
let list = tasks.filter((t) => !t.done);
// Bucket filter
if (activeBucket !== "all") {
list = list.filter((t) => t.bucket === activeBucket);
}
// List filter
if (activeListId !== "all") {
if (activeListId === "inbox") {
list = list.filter((t) => !t.listId);
} else {
list = list.filter((t) => t.listId === activeListId);
}
}
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter((t) => t.text.toLowerCase().includes(q));
}
// Sort
if (sortMode === "quick-first") {
list.sort((a, b) => (EFFORT_ORDER[a.effort] || 4) - (EFFORT_ORDER[b.effort] || 4));
} else if (sortMode === "deep-first") {
list.sort((a, b) => (EFFORT_ORDER[b.effort] || 4) - (EFFORT_ORDER[a.effort] || 4));
}
return list;
});
let completedTasks = $derived.by(() => {
let list = tasks.filter((t) => t.done);
if (activeBucket !== "all") {
list = list.filter((t) => t.bucket === activeBucket);
}
if (activeListId !== "all") {
if (activeListId === "inbox") {
list = list.filter((t) => !t.listId);
} else {
list = list.filter((t) => t.listId === activeListId);
}
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter((t) => t.text.toLowerCase().includes(q));
}
return list.slice(0, 20);
});
let bucketCounts = $derived.by(() => {
const counts = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
for (const t of tasks) {
if (t.done) continue;
counts.all++;
if (counts[t.bucket] !== undefined) counts[t.bucket]++;
}
return counts;
});
function countForList(listId) {
if (listId === "all") return tasks.filter((t) => !t.done).length;
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
return tasks.filter((t) => !t.done && t.listId === listId).length;
}
let activeListName = $derived(
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
);
function handleQuickAdd(e) {
if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
addTask({
text: quickInput.trim(),
bucket: activeBucket === "all" ? "inbox" : activeBucket,
listId,
});
quickInput = "";
}
}
function setBucket(taskId, bucket) {
updateTask(taskId, { bucket });
}
function setEffort(taskId, effort) {
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
}
function getListName(listId) {
if (!listId) return null;
return taskLists.find((l) => l.id === listId)?.name || null;
}
async function popOutTasks() {
try {
await invoke("open_task_window");
} catch {
window.open("/float", "_blank", "width=380,height=520");
}
}
// List management
function handleCreateList(e) {
if (e.key === "Enter" && newListName.trim()) {
addTaskList(newListName.trim());
newListName = "";
showNewList = false;
}
if (e.key === "Escape") { showNewList = false; newListName = ""; }
}
function startRenaming(list) {
editingListId = list.id;
editingName = list.name;
contextMenuListId = null;
}
function finishRenaming() {
if (editingListId && editingName.trim()) {
renameTaskList(editingListId, editingName.trim());
}
editingListId = null;
editingName = "";
}
function handleDeleteList(id) {
if (activeListId === id) activeListId = "all";
deleteTaskList(id);
contextMenuListId = null;
}
function toggleContextMenu(e, listId) {
e.preventDefault();
e.stopPropagation();
contextMenuListId = contextMenuListId === listId ? null : listId;
}
</script>
<div class="flex flex-col h-full animate-fade-in">
<!-- Header -->
<div class="flex items-center px-7 pt-6 pb-2">
<div>
<h2 class="text-xl font-display text-text italic">Tasks</h2>
<p class="text-[11px] text-text-tertiary mt-1">Extracted from transcripts or added manually</p>
</div>
<div class="flex-1"></div>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text"
onclick={popOutTasks}
aria-label="Pop out task window"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
</svg>
Pop out
</button>
</div>
<!-- Search -->
<div class="px-7 pb-2">
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent transition-colors">
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
</svg>
<input
type="text"
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
placeholder="Search tasks..."
bind:value={searchQuery}
data-no-transition
/>
{#if searchQuery}
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
{/if}
</div>
</div>
<!-- Quick capture -->
<div class="px-7 pb-3">
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-xl px-4 py-2.5 focus-within:border-accent transition-colors">
<svg class="w-4 h-4 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" stroke-linecap="round" />
</svg>
<input
type="text"
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary focus:outline-none"
placeholder="Add a task to {activeListName}{activeBucket !== 'all' ? ` (${activeBucket})` : ''}... (Enter to save)"
bind:value={quickInput}
onkeydown={handleQuickAdd}
/>
{#if quickInput.trim()}
<span class="text-[10px] text-text-tertiary px-1.5 py-0.5 rounded bg-bg-elevated border border-border-subtle">
&rarr; {activeBucket === "all" ? "inbox" : activeBucket}
</span>
{/if}
</div>
</div>
<!-- Bucket tabs + sort -->
<div class="flex items-center gap-1 px-7 pb-3">
{#each buckets as bucket}
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] transition-colors
{activeBucket === bucket.id
? 'bg-nav-active text-text font-medium'
: 'text-text-secondary hover:bg-hover hover:text-text'}"
onclick={() => activeBucket = bucket.id}
>
<svg class="w-3.5 h-3.5 {activeBucket === bucket.id ? 'text-accent' : 'text-text-tertiary'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={bucket.icon} />
</svg>
{bucket.label}
{#if bucketCounts[bucket.id] > 0}
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{bucketCounts[bucket.id]}
</span>
{/if}
</button>
{/each}
<div class="flex-1"></div>
<!-- Sort dropdown -->
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[11px] text-text-tertiary hover:text-text-secondary hover:bg-hover"
onclick={() => showSortMenu = !showSortMenu}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
</svg>
{sortMode === "date" ? "" : sortMode === "quick-first" ? "Quick first" : "Deep first"}
</button>
{#if showSortMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]">
{#each [["date", "By date"], ["quick-first", "Quick first"], ["deep-first", "Deep first"]] as [mode, label]}
<button
class="w-full text-left px-3 py-1 text-[11px] hover:bg-hover
{sortMode === mode ? 'text-accent font-medium' : 'text-text-secondary hover:text-text'}"
onclick={() => { sortMode = mode; showSortMenu = false; }}
>{label}</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Main content: sidebar + tasks -->
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
<!-- List sidebar -->
<div
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden transition-[width] duration-150
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
>
<!-- Collapse toggle -->
<button
class="flex items-center justify-center h-[32px] text-text-tertiary hover:text-text-secondary border-b border-border-subtle"
onclick={() => sidebarCollapsed = !sidebarCollapsed}
>
<svg class="w-3 h-3 transition-transform {sidebarCollapsed ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<!-- List items -->
<div class="flex-1 overflow-y-auto py-1">
{#each taskLists as list (list.id)}
{#if editingListId === list.id && !sidebarCollapsed}
<div class="px-1.5 py-0.5">
<input
type="text"
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
onblur={finishRenaming}
data-no-transition
autofocus
/>
</div>
{:else}
<div class="relative">
<button
class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left text-[11px]
{activeListId === list.id
? 'border-l-2 border-accent text-text font-medium bg-accent/5'
: 'border-l-2 border-transparent text-text-secondary hover:bg-hover hover:text-text'}"
onclick={() => { activeListId = list.id; contextMenuListId = null; }}
ondblclick={() => { if (!list.builtIn && !sidebarCollapsed) startRenaming(list); }}
oncontextmenu={(e) => { if (!list.builtIn) toggleContextMenu(e, list.id); }}
title={sidebarCollapsed ? `${list.name} (${countForList(list.id)})` : ""}
>
{#if sidebarCollapsed}
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center mx-auto">
{countForList(list.id)}
</span>
{:else}
<span class="flex-1 truncate">{list.name}</span>
{#if countForList(list.id) > 0}
<span class="text-[9px] px-1 py-0 rounded-full bg-bg-card text-text-tertiary min-w-[16px] text-center">
{countForList(list.id)}
</span>
{/if}
{/if}
</button>
<!-- Context menu -->
{#if contextMenuListId === list.id && !sidebarCollapsed}
<div class="absolute left-2 top-full mt-0.5 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[100px]">
<button
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => startRenaming(list)}
>Rename</button>
<div class="my-1 h-px bg-border-subtle"></div>
<button
class="w-full text-left px-3 py-1 text-[10px] text-danger hover:bg-hover"
onclick={() => handleDeleteList(list.id)}
>Delete</button>
</div>
{/if}
</div>
{/if}
{/each}
</div>
<!-- New list -->
{#if !sidebarCollapsed}
<div class="px-2 py-2 border-t border-border-subtle">
{#if showNewList}
<input
type="text"
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="List name..."
bind:value={newListName}
onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition
autofocus
/>
{:else}
<button
class="w-full text-left text-[10px] text-accent hover:text-accent-hover px-1"
onclick={() => showNewList = true}
>+ New list</button>
{/if}
</div>
{/if}
</div>
<!-- Task list -->
<div class="flex-1 overflow-y-auto min-h-0">
{#if filteredTasks.length === 0}
<div class="flex flex-col items-center justify-center h-full text-center py-12 opacity-60">
<svg class="w-10 h-10 text-text-tertiary mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="text-[13px] text-text-secondary">
{searchQuery ? "No matching tasks"
: activeListId !== "all" && activeBucket !== "all"
? `No ${activeBucket} tasks in ${activeListName}`
: activeListId !== "all"
? `No tasks in ${activeListName}`
: activeBucket === "all" ? "No tasks yet" : `Nothing in ${activeBucket}`}
</p>
<p class="text-[11px] text-text-tertiary mt-1">
{searchQuery ? "Try a different search" : "Tasks are extracted from transcripts or added above"}
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each filteredTasks as task (task.id)}
<div class="group flex items-start gap-3 px-4 py-3 rounded-xl bg-bg-card border border-border-subtle hover:border-border transition-colors">
<!-- Checkbox -->
<button
aria-label="Complete task"
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-border-subtle hover:border-accent flex-shrink-0 flex items-center justify-center transition-colors"
onclick={() => completeTask(task.id)}
></button>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
<div class="flex items-center gap-2 mt-1.5">
<!-- Bucket pills -->
{#each ["today", "soon", "later"] as b}
<button
class="text-[10px] px-2 py-0.5 rounded-full border transition-colors
{task.bucket === b
? `${BUCKET_COLORS[b]} border-current bg-current/10 font-medium`
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
onclick={() => setBucket(task.id, b)}
>{b}</button>
{/each}
<span class="text-border-subtle">&middot;</span>
<!-- Effort pills -->
{#each ["quick", "medium", "deep"] as e}
<button
class="text-[10px] px-2 py-0.5 rounded-full border transition-colors
{task.effort === e
? 'text-accent border-accent/30 bg-accent/10 font-medium'
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
onclick={() => setEffort(task.id, e)}
>{EFFORT_LABELS[e]}</button>
{/each}
<!-- Timestamp -->
{#if task.createdAt}
<span class="text-[10px] text-text-tertiary ml-auto">{formatTimestamp(task.createdAt)}</span>
{/if}
</div>
<!-- List name (only when viewing all lists) -->
{#if activeListId === "all" && getListName(task.listId)}
<p class="text-[10px] text-text-tertiary italic mt-1">{getListName(task.listId)}</p>
{/if}
</div>
<!-- Delete -->
<button
aria-label="Delete task"
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => deleteTask(task.id)}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Completed section -->
{#if completedTasks.length > 0}
<div class="mt-6">
<button
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
onclick={() => showCompleted = !showCompleted}
>
<svg class="w-3 h-3 transition-transform {showCompleted ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5l8 7-8 7z" />
</svg>
Completed ({completedTasks.length})
</button>
{#if showCompleted}
<div class="flex flex-col gap-1 animate-fade-in">
{#each completedTasks as task (task.id)}
<div class="group flex items-start gap-3 px-4 py-2.5 rounded-xl opacity-50 hover:opacity-70 transition-opacity">
<button
aria-label="Uncomplete task"
class="mt-0.5 w-[18px] h-[18px] rounded-md border-2 border-accent bg-accent/20 flex-shrink-0 flex items-center justify-center"
onclick={() => uncompleteTask(task.id)}
>
<svg class="w-3 h-3 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text-secondary line-through">{task.text}</p>
{#if task.doneAt}
<p class="text-[10px] text-text-tertiary mt-0.5">Completed {formatTimestamp(task.doneAt)}</p>
{/if}
</div>
<button
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => deleteTask(task.id)}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>