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>
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
target/
|
||||
build/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
Cargo.lock
|
||||
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["src-tauri", "crates/*"]
|
||||
resolver = "2"
|
||||
8
crates/ai-formatting/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-ai-formatting"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
2
crates/ai-formatting/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-ai-formatting: Filler removal, British English conversion,
|
||||
// text formatting, hallucination filtering, and paragraph breaks.
|
||||
8
crates/audio/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-audio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
2
crates/audio/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-audio: Microphone capture, voice activity detection, resampling,
|
||||
// file decoding, and WAV I/O.
|
||||
8
crates/cloud-providers/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-cloud-providers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "BYOK cloud STT provider stubs and API key storage for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
2
crates/cloud-providers/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-cloud-providers: BYOK cloud STT provider implementations
|
||||
// and OS keychain API key storage.
|
||||
7
crates/core/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "kon-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core types, constants, traits, hardware detection, and model registry for Kon"
|
||||
|
||||
[dependencies]
|
||||
2
crates/core/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-core: Foundation types, constants, provider traits, hardware detection,
|
||||
// model registry, and recommendation engine.
|
||||
8
crates/storage/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "SQLite persistence, BM25 search, and file storage for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
2
crates/storage/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-storage: SQLite via sqlx, BM25 full-text search via probly-search,
|
||||
// and file storage path management.
|
||||
8
crates/transcription/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kon-transcription"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
2
crates/transcription/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// kon-transcription: transcribe-rs wrapper behind SpeechToText trait,
|
||||
// unified model manager, and inference threading.
|
||||
14
jsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
2579
package-lock.json
generated
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "kon",
|
||||
"version": "0.1.0",
|
||||
"description": "Kon — Think out loud",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
2
src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin"
|
||||
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "kon"
|
||||
version = "0.1.0"
|
||||
description = "Kon — Think out loud"
|
||||
authors = ["CORBEL Ltd"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "kon_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Workspace crates
|
||||
kon-core = { path = "../crates/core" }
|
||||
kon-audio = { path = "../crates/audio" }
|
||||
kon-transcription = { path = "../crates/transcription" }
|
||||
kon-ai-formatting = { path = "../crates/ai-formatting" }
|
||||
kon-storage = { path = "../crates/storage" }
|
||||
kon-cloud-providers = { path = "../crates/cloud-providers" }
|
||||
|
||||
# Tauri
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
22
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "tasks-float"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
||||
2609
src-tauri/gen/schemas/desktop-schema.json
Normal file
2609
src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
9
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Kon");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
kon_lib::run()
|
||||
}
|
||||
40
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kon",
|
||||
"version": "0.1.0",
|
||||
"identifier": "uk.co.corbel.kon",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Kon",
|
||||
"width": 1020,
|
||||
"height": 720,
|
||||
"minWidth": 1020,
|
||||
"minHeight": 540,
|
||||
"decorations": false,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
284
src/app.css
Normal file
@@ -0,0 +1,284 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Instrument+Serif:ital@0;1&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* === Ramble Design Tokens === */
|
||||
@theme {
|
||||
/* Surfaces — warm layered depth */
|
||||
--color-bg: #0f0e0c;
|
||||
--color-bg-elevated: #171614;
|
||||
--color-bg-card: #1b1a17;
|
||||
--color-bg-input: #151412;
|
||||
--color-border: #2c2923;
|
||||
--color-border-subtle: #221f1b;
|
||||
|
||||
/* Text — warm hierarchy */
|
||||
--color-text: #f0ece4;
|
||||
--color-text-secondary: #9a9486;
|
||||
--color-text-tertiary: #5c574d;
|
||||
|
||||
/* Accent — warm amber/copper */
|
||||
--color-accent: #e8a87c;
|
||||
--color-accent-hover: #d4976a;
|
||||
--color-accent-subtle: #e8a87c10;
|
||||
--color-accent-glow: #e8a87c25;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #7ec89a;
|
||||
--color-danger: #e87171;
|
||||
--color-warning: #e8c86e;
|
||||
|
||||
/* Layout */
|
||||
--color-sidebar: #13120f;
|
||||
--color-nav-active: #201e1a;
|
||||
--color-hover: #1e1c18;
|
||||
}
|
||||
|
||||
/* === Light theme overrides === */
|
||||
html.light {
|
||||
--color-bg: #faf8f5;
|
||||
--color-bg-elevated: #f3f0eb;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-input: #f0ede8;
|
||||
--color-border: #ddd8d0;
|
||||
--color-border-subtle: #e6e2da;
|
||||
|
||||
--color-text: #1a1816;
|
||||
--color-text-secondary: #5c574d;
|
||||
--color-text-tertiary: #9a9486;
|
||||
|
||||
--color-accent: #d4956a;
|
||||
--color-accent-hover: #c48a60;
|
||||
--color-accent-subtle: #d4956a10;
|
||||
--color-accent-glow: #d4956a25;
|
||||
|
||||
--color-success: #4a9e6a;
|
||||
--color-danger: #d45454;
|
||||
--color-warning: #c9a84e;
|
||||
|
||||
--color-sidebar: #f5f2ed;
|
||||
--color-nav-active: #eae6e0;
|
||||
--color-hover: #ede9e3;
|
||||
}
|
||||
|
||||
html.light ::-webkit-scrollbar-thumb {
|
||||
background: #d0ccc4;
|
||||
}
|
||||
html.light ::-webkit-scrollbar-thumb:hover {
|
||||
background: #b8b3aa;
|
||||
}
|
||||
html.light .grain::after {
|
||||
opacity: 0.015;
|
||||
}
|
||||
|
||||
/* === Base === */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* === Display font === */
|
||||
.font-display {
|
||||
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* === Transcript font — optimised for reading === */
|
||||
.font-transcript {
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: var(--font-size-transcript, 15px);
|
||||
line-height: 1.85;
|
||||
letter-spacing: 0.01em;
|
||||
font-feature-settings: "liga" 1, "kern" 1;
|
||||
}
|
||||
|
||||
/* === Grain texture overlay === */
|
||||
.grain::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
z-index: 50;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* === Scrollbar — thin, appears on hover === */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2c2923;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3d3830;
|
||||
}
|
||||
|
||||
/* === Smooth transitions (global) === */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, opacity, box-shadow;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Opt out for inputs */
|
||||
textarea, input, [data-no-transition] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* === Focus ring — consistent, accessible === */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* === Selection colour === */
|
||||
::selection {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes pulse-warm {
|
||||
0%, 100% { box-shadow: 0 0 24px rgba(232, 168, 124, 0.25), 0 0 48px rgba(232, 168, 124, 0.08); }
|
||||
50% { box-shadow: 0 0 32px rgba(232, 168, 124, 0.4), 0 0 64px rgba(232, 168, 124, 0.15); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-warm {
|
||||
animation: pulse-warm 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 250ms ease-out;
|
||||
}
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* === Task sidebar slide === */
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slide-out-right {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Task extraction highlight */
|
||||
@keyframes highlight-warm {
|
||||
0% { border-left-color: var(--color-accent); }
|
||||
100% { border-left-color: transparent; }
|
||||
}
|
||||
|
||||
/* Checkbox pop */
|
||||
@keyframes scale-pop {
|
||||
0% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 200ms ease-out;
|
||||
}
|
||||
.animate-highlight-warm {
|
||||
border-left: 3px solid transparent;
|
||||
animation: highlight-warm 2s ease-out;
|
||||
}
|
||||
.animate-scale-pop {
|
||||
animation: scale-pop 50ms ease-out;
|
||||
}
|
||||
|
||||
/* Window focus glow */
|
||||
@keyframes glow-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
|
||||
30% { box-shadow: 0 0 20px 4px rgba(232, 168, 124, 0.35), inset 0 0 12px rgba(232, 168, 124, 0.08); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 700ms ease-out;
|
||||
}
|
||||
|
||||
html.light .animate-glow-pulse {
|
||||
animation: glow-pulse-light 700ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse-light {
|
||||
0% { box-shadow: 0 0 0 0 rgba(212, 149, 106, 0); }
|
||||
30% { box-shadow: 0 0 20px 4px rgba(212, 149, 106, 0.3), inset 0 0 12px rgba(212, 149, 106, 0.06); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(212, 149, 106, 0); }
|
||||
}
|
||||
|
||||
/* Float window entrance */
|
||||
@keyframes float-enter {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-float-enter {
|
||||
animation: float-enter 200ms ease-out;
|
||||
}
|
||||
|
||||
/* === Custom titlebar buttons === */
|
||||
.titlebar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 32px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Sinhala character spin (decorative, during recording) */
|
||||
@keyframes sinhala-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
.animate-sinhala-spin {
|
||||
animation: sinhala-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Recording indicator dot (follows mouse via JS, see +layout.svelte) */
|
||||
13
src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Ramble</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
107
src/lib/Sidebar.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script>
|
||||
import { page, settings, profiles, tasks } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
const navItems = [
|
||||
{ id: "dictation", label: "Dictation", icon: "mic" },
|
||||
{ id: "files", label: "Files", icon: "file" },
|
||||
{ id: "tasks", label: "Tasks", icon: "tasks" },
|
||||
{ id: "history", label: "History", icon: "clock" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const icons = {
|
||||
mic: "M12 1a4 4 0 0 0-4 4v7a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4ZM8 17.95A7 7 0 0 1 5 12h2a5 5 0 0 0 10 0h2a7 7 0 0 1-3 5.95V21h3v2H5v-2h3v-3.05Z",
|
||||
file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Zm4 18H6V4h7v5h5v11Z",
|
||||
tasks: "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
|
||||
clock: "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",
|
||||
user: "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",
|
||||
settings: "M19.14 12.94a7.07 7.07 0 0 0 .06-.94 7.07 7.07 0 0 0-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.04 7.04 0 0 0-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58a7.07 7.07 0 0 0 0 1.88l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.49.37 1.03.7 1.62.94l.36 2.54c.05.24.26.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58ZM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2Z",
|
||||
};
|
||||
|
||||
function navigate(id) {
|
||||
page.current = id;
|
||||
if (id !== "dictation") {
|
||||
page.taskSidebarOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col w-[210px] min-w-[210px] bg-sidebar border-r border-border-subtle h-full">
|
||||
<!-- Logo -->
|
||||
<div class="px-5 pt-4 pb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="font-display text-[26px] text-text tracking-tight leading-none italic">Ramble</h1>
|
||||
<span
|
||||
class="text-[18px] text-accent inline-block {page.recording ? 'animate-sinhala-spin' : ''}"
|
||||
title="Ramble"
|
||||
>෧</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-text-tertiary mt-1.5 tracking-[0.12em] uppercase">Think out loud</p>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="mx-5 my-5 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex flex-col gap-0.5 px-3">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class="group flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] text-left w-full
|
||||
{page.current === item.id
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
onclick={() => navigate(item.id)}
|
||||
>
|
||||
<svg
|
||||
class="w-[16px] h-[16px] flex-shrink-0
|
||||
{page.current === item.id ? 'text-accent' : 'text-text-tertiary group-hover:text-text-secondary'}"
|
||||
viewBox="0 0 24 24" fill="currentColor"
|
||||
>
|
||||
<path d={icons[item.icon]} />
|
||||
</svg>
|
||||
{item.label}
|
||||
{#if item.id === "tasks" && taskCount > 0}
|
||||
<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">
|
||||
{taskCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Profile selector -->
|
||||
<div class="px-4 pb-3">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5 px-1">Profile</p>
|
||||
<select
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text-secondary
|
||||
focus:outline-none focus:border-accent appearance-none cursor-pointer"
|
||||
bind:value={page.activeProfile}
|
||||
>
|
||||
<option>None</option>
|
||||
{#each profiles as profile}
|
||||
<option>{profile.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="mx-5 mb-3 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-[7px] h-[7px] rounded-full"
|
||||
class:animate-pulse-soft={page.recording}
|
||||
style="background: {page.statusColor}"
|
||||
></span>
|
||||
<span class="text-[11px] text-text-secondary">{page.status}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-text-tertiary mt-1.5">{settings.formatMode} · v0.2</p>
|
||||
</div>
|
||||
</aside>
|
||||
7
src/lib/components/Card.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
let { children, classes = "" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-bg-card border border-border rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,0.2)] {classes}">
|
||||
{@render children()}
|
||||
</div>
|
||||
73
src/lib/components/HotkeyRecorder.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let recording = $state(false);
|
||||
let captured = $state(false);
|
||||
|
||||
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Wait for a non-modifier key
|
||||
if (modifierKeys.has(e.key)) return;
|
||||
|
||||
const parts = [];
|
||||
if (e.ctrlKey) parts.push("Ctrl");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.metaKey) parts.push("Super");
|
||||
|
||||
// Must have at least one modifier
|
||||
if (parts.length === 0) {
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
||||
settings.globalHotkey = parts.join("+");
|
||||
saveSettings();
|
||||
recording = false;
|
||||
captured = true;
|
||||
setTimeout(() => { captured = false; }, 1500);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
recording = false;
|
||||
}
|
||||
|
||||
let chips = $derived(settings.globalHotkey.split("+"));
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
onkeydown={handleKeyDown}
|
||||
onblur={handleBlur}
|
||||
aria-label="Record hotkey"
|
||||
>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Press new shortcut...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary">+</span>
|
||||
{/if}
|
||||
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
|
||||
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</button>
|
||||
105
src/lib/components/ModelDownloader.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
|
||||
let { modelSize = "base", onComplete = () => {} } = $props();
|
||||
|
||||
let progress = $state(0);
|
||||
let downloading = $state(false);
|
||||
let downloaded = $state(0);
|
||||
let total = $state(0);
|
||||
let error = $state("");
|
||||
let unlisten = null;
|
||||
|
||||
const modelInfo = {
|
||||
tiny: { size: "~75 MB", accuracy: "Basic" },
|
||||
base: { size: "~142 MB", accuracy: "Good" },
|
||||
small: { size: "~466 MB", accuracy: "Better" },
|
||||
medium: { size: "~1.5 GB", accuracy: "Best" },
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen("model-download-progress", (event) => {
|
||||
const data = event.payload;
|
||||
progress = data.progress;
|
||||
downloaded = data.downloaded;
|
||||
total = data.total;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
});
|
||||
|
||||
async function startDownload() {
|
||||
downloading = true;
|
||||
error = "";
|
||||
progress = 0;
|
||||
try {
|
||||
await invoke("download_model", { size: modelSize });
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Download failed";
|
||||
downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
return `${(bytes / 1073741824).toFixed(2)} GB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full px-8 animate-fade-in">
|
||||
<Card classes="max-w-md w-full">
|
||||
<div class="p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-5">
|
||||
<svg class="w-8 h-8 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
|
||||
<p class="text-[13px] text-text-secondary mb-1">
|
||||
Ramble needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-6">
|
||||
{modelInfo[modelSize]?.size ?? "?"} · {modelInfo[modelSize]?.accuracy ?? "?"} accuracy · 100% offline
|
||||
</p>
|
||||
|
||||
{#if downloading}
|
||||
<div class="mb-4">
|
||||
<div class="h-[6px] bg-bg-elevated rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-300 shadow-[0_0_8px_rgba(232,168,124,0.4)]"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
{progress}% · {formatBytes(downloaded)} / {formatBytes(total)}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-xl text-[14px] font-medium text-white bg-accent hover:bg-accent-hover
|
||||
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
|
||||
onclick={startDownload}
|
||||
>
|
||||
Download Model
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-[12px] text-danger mt-3">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-[10px] text-text-tertiary mt-5">
|
||||
Models are cached locally. No data leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
18
src/lib/components/SegmentedButton.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
let { options = [], value = $bindable(""), size = "default" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="inline-flex bg-bg-elevated rounded-[10px] p-[3px] gap-[2px]">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="rounded-lg font-medium
|
||||
{size === 'small' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-[6px] text-[12px]'}
|
||||
{value === option
|
||||
? 'bg-accent text-white shadow-[0_1px_4px_rgba(232,168,124,0.3)]'
|
||||
: 'text-text-secondary hover:text-text hover:bg-hover'}"
|
||||
onclick={() => value = option}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
95
src/lib/components/TaskSidebar.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
|
||||
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
|
||||
|
||||
let quickInput = $state("");
|
||||
let recentlyAdded = $state(new Set());
|
||||
|
||||
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
|
||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||
|
||||
function handleQuickAdd(e) {
|
||||
if (e.key === "Enter" && quickInput.trim()) {
|
||||
addTask({ text: quickInput.trim(), bucket: "inbox" });
|
||||
quickInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
page.taskSidebarOpen = false;
|
||||
}
|
||||
|
||||
// Track recently added tasks for highlight animation
|
||||
let prevTaskIds = $state(new Set(tasks.map((t) => t.id)));
|
||||
$effect(() => {
|
||||
const currentIds = new Set(tasks.map((t) => t.id));
|
||||
for (const id of currentIds) {
|
||||
if (!prevTaskIds.has(id)) {
|
||||
recentlyAdded.add(id);
|
||||
setTimeout(() => { recentlyAdded.delete(id); }, 2000);
|
||||
}
|
||||
}
|
||||
prevTaskIds = currentIds;
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col w-[280px] min-w-[280px] bg-sidebar border-l border-border-subtle h-full animate-slide-in-right">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 px-4 pt-4 pb-3">
|
||||
<h3 class="text-[13px] font-semibold text-text">Tasks</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{taskCount}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="text-[11px] text-text-tertiary hover:text-accent"
|
||||
onclick={() => page.current = "tasks"}
|
||||
>View all</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text rounded"
|
||||
onclick={close}
|
||||
aria-label="Close task sidebar"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
|
||||
<!-- Quick add -->
|
||||
<div class="px-3 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
class="w-full 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="Add task..."
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto px-3 pb-3 min-h-0">
|
||||
{#if activeTasks.length === 0}
|
||||
<p class="text-[11px] text-text-tertiary text-center py-6 opacity-60">No active tasks</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each activeTasks as task (task.id)}
|
||||
<div
|
||||
class="group flex items-start gap-2 px-2.5 py-1.5 rounded-lg hover:bg-hover
|
||||
{recentlyAdded.has(task.id) ? 'animate-highlight-warm' : ''}"
|
||||
>
|
||||
<button
|
||||
class="mt-0.5 w-4 h-4 rounded border-[1.5px] border-border-subtle hover:border-accent flex-shrink-0"
|
||||
onclick={() => completeTask(task.id)}
|
||||
aria-label="Complete task"
|
||||
></button>
|
||||
<span class="text-[11px] text-text leading-snug flex-1 min-w-0">{task.text}</span>
|
||||
<span class="w-2 h-2 rounded-full mt-1 flex-shrink-0 {BUCKET_DOT_COLORS[task.bucket] || 'bg-text-tertiary'}"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
70
src/lib/components/Titlebar.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
let maximised = $state(false);
|
||||
|
||||
async function handleMinimise() {
|
||||
await getCurrentWindow().minimize();
|
||||
}
|
||||
|
||||
async function handleMaximise() {
|
||||
await getCurrentWindow().toggleMaximize();
|
||||
maximised = await getCurrentWindow().isMaximized();
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
await getCurrentWindow().hide();
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
// Track maximise state
|
||||
$effect(() => {
|
||||
let unlisten;
|
||||
getCurrentWindow().onResized(async () => {
|
||||
maximised = await getCurrentWindow().isMaximized();
|
||||
}).then((fn) => unlisten = fn);
|
||||
return () => unlisten?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center h-[32px] select-none bg-sidebar border-b border-border-subtle"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- Left spacer: aligns with sidebar width -->
|
||||
<div class="w-[210px] min-w-[210px]" data-tauri-drag-region></div>
|
||||
|
||||
<!-- Centre: drag area -->
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
|
||||
<!-- Window controls -->
|
||||
<div class="flex items-center h-full">
|
||||
<button class="titlebar-btn hover:bg-hover" onclick={handleMinimise} aria-label="Minimise">
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<rect y="5" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="titlebar-btn hover:bg-hover" onclick={handleMaximise} aria-label="Maximise">
|
||||
{#if maximised}
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<path d="M3 1h8v8h-2v2H1V3h2V1zm1 1v1h5v5h1V2H4zm-2 2v6h6V4H2z" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="titlebar-btn hover:bg-danger/20 hover:text-danger" onclick={handleClose} aria-label="Close">
|
||||
<svg class="w-3 h-3" viewBox="0 0 12 12">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
27
src/lib/components/Toggle.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
let { checked = $bindable(false), label = "", description = "" } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3 py-2.5">
|
||||
<button
|
||||
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
|
||||
{checked ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-border'}
|
||||
active:scale-95 transition-all duration-200"
|
||||
onclick={() => checked = !checked}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||
transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]
|
||||
{checked ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-tight">{label}</p>
|
||||
{#if description}
|
||||
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
29
src/lib/components/UnicodeSpinner.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let { type = "dots", speed = 100, classes = "" } = $props();
|
||||
|
||||
const sequences = {
|
||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
circle: ["◴", "◷", "◶", "◵"],
|
||||
pulse: ["◉", "◎", "○", "◎"],
|
||||
stars: ["·", "✢", "✳", "✶", "✻", "✽"],
|
||||
orbit: ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
|
||||
};
|
||||
|
||||
let frame = $state(0);
|
||||
let interval = null;
|
||||
let chars = sequences[type] || sequences.dots;
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
frame = (frame + 1) % chars.length;
|
||||
}, speed);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="inline-block {classes}" aria-hidden="true">{chars[frame]}</span>
|
||||
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>
|
||||
241
src/lib/pages/FilesPage.svelte
Normal 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>
|
||||
309
src/lib/pages/HistoryPage.svelte
Normal 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>
|
||||
264
src/lib/pages/ProfilesPage.svelte
Normal 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 Jake Sames Northampton ..."
|
||||
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 Background Key Findings 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>
|
||||
554
src/lib/pages/SettingsPage.svelte
Normal 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>
|
||||
512
src/lib/pages/TasksPage.svelte
Normal 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">
|
||||
→ {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">·</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>
|
||||
352
src/lib/stores/page.svelte.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/** @type {{ current: string, status: string, statusColor: string, activeProfile: string, recording: boolean, timerText: string, handedness: string, taskSidebarOpen: boolean }} */
|
||||
export const page = $state({
|
||||
current: "dictation",
|
||||
status: "Ready",
|
||||
statusColor: "#7ec89a",
|
||||
activeProfile: "None",
|
||||
recording: false,
|
||||
timerText: "00:00",
|
||||
handedness: "Right",
|
||||
taskSidebarOpen: false,
|
||||
});
|
||||
|
||||
// ---- Settings (persisted to localStorage) ----
|
||||
|
||||
const SETTINGS_KEY = "ramble_settings";
|
||||
|
||||
const defaults = {
|
||||
engine: "whisper",
|
||||
modelSize: "Base",
|
||||
language: "en",
|
||||
device: "auto",
|
||||
formatMode: "Smart",
|
||||
removeFillers: true,
|
||||
antiHallucination: true,
|
||||
britishEnglish: true,
|
||||
autoCopy: true,
|
||||
includeTimestamps: true,
|
||||
theme: "Dark",
|
||||
fontSize: 14,
|
||||
llmModelSize: "small",
|
||||
llmEnabled: false,
|
||||
saveAudio: false,
|
||||
outputFolder: "",
|
||||
globalHotkey: "Ctrl+Shift+R",
|
||||
};
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
return { ...defaults };
|
||||
}
|
||||
|
||||
export const settings = $state(loadSettings());
|
||||
|
||||
/** Save current settings to localStorage */
|
||||
export function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- Profiles (persisted to localStorage) ----
|
||||
|
||||
const PROFILES_KEY = "ramble_profiles";
|
||||
|
||||
function loadProfiles() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const profiles = $state(loadProfiles());
|
||||
|
||||
export function saveProfiles() {
|
||||
try {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- History (persisted to localStorage) ----
|
||||
|
||||
const HISTORY_KEY = "ramble_history";
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const history = $state(loadHistory());
|
||||
|
||||
export function saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function addToHistory(entry) {
|
||||
history.unshift(entry);
|
||||
if (history.length > 100) history.length = 100;
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
export function deleteFromHistory(index) {
|
||||
history.splice(index, 1);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
// ---- Tasks (persisted to localStorage) ----
|
||||
|
||||
const TASKS_KEY = "ramble_tasks";
|
||||
|
||||
function loadTasks() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TASKS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const tasks = $state(loadTasks());
|
||||
|
||||
export function saveTasks() {
|
||||
try {
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function addTask(task) {
|
||||
tasks.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
text: task.text,
|
||||
bucket: task.bucket || "inbox",
|
||||
listId: task.listId || null,
|
||||
context: task.context || "",
|
||||
effort: task.effort || "",
|
||||
done: false,
|
||||
doneAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceTranscriptId: task.sourceTranscriptId || null,
|
||||
notes: "",
|
||||
});
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
|
||||
export function updateTask(id, updates) {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
Object.assign(tasks[idx], updates);
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTask(id) {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
}
|
||||
}
|
||||
|
||||
export function completeTask(id) {
|
||||
updateTask(id, { done: true, doneAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
export function uncompleteTask(id) {
|
||||
updateTask(id, { done: false, doneAt: null });
|
||||
}
|
||||
|
||||
// ---- BroadcastChannel for multi-window task sync ----
|
||||
|
||||
const taskChannel = typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel("ramble_tasks")
|
||||
: null;
|
||||
|
||||
if (taskChannel) {
|
||||
taskChannel.onmessage = (event) => {
|
||||
if (event.data.type === "tasks_updated") {
|
||||
tasks.length = 0;
|
||||
tasks.push(...event.data.tasks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastTasks() {
|
||||
if (taskChannel) {
|
||||
taskChannel.postMessage({ type: "tasks_updated", tasks: $state.snapshot(tasks) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Task Lists (persisted to localStorage) ----
|
||||
|
||||
const TASK_LISTS_KEY = "ramble_task_lists";
|
||||
|
||||
function loadTaskLists() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TASK_LISTS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [
|
||||
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
||||
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
|
||||
];
|
||||
}
|
||||
|
||||
export const taskLists = $state(loadTaskLists());
|
||||
|
||||
export function saveTaskLists() {
|
||||
try {
|
||||
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
|
||||
} catch {}
|
||||
broadcastTaskLists();
|
||||
}
|
||||
|
||||
export function addTaskList(name, profileId = null) {
|
||||
taskLists.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
builtIn: false,
|
||||
profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
export function addProfileTaskList(profileName) {
|
||||
const exists = taskLists.find((l) => l.profileId === profileName);
|
||||
if (exists) return exists.id;
|
||||
const id = crypto.randomUUID();
|
||||
taskLists.push({
|
||||
id,
|
||||
name: profileName,
|
||||
builtIn: false,
|
||||
profileId: profileName,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
saveTaskLists();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeProfileTaskList(profileName) {
|
||||
const idx = taskLists.findIndex((l) => l.profileId === profileName);
|
||||
if (idx >= 0) {
|
||||
const listId = taskLists[idx].id;
|
||||
for (const t of tasks) {
|
||||
if (t.listId === listId) t.listId = null;
|
||||
}
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
taskLists.splice(idx, 1);
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function moveTaskToList(taskId, listId) {
|
||||
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
|
||||
}
|
||||
|
||||
export function moveTaskListToProfile(listId, profileId) {
|
||||
const list = taskLists.find((l) => l.id === listId);
|
||||
if (list && !list.builtIn) {
|
||||
list.profileId = profileId || null;
|
||||
if (profileId) list.name = profileId;
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function renameTaskList(id, name) {
|
||||
const list = taskLists.find((l) => l.id === id);
|
||||
if (list && !list.builtIn) {
|
||||
list.name = name;
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTaskList(id) {
|
||||
const idx = taskLists.findIndex((l) => l.id === id);
|
||||
if (idx >= 0 && !taskLists[idx].builtIn) {
|
||||
for (const t of tasks) {
|
||||
if (t.listId === id) t.listId = null;
|
||||
}
|
||||
saveTasks();
|
||||
broadcastTasks();
|
||||
taskLists.splice(idx, 1);
|
||||
saveTaskLists();
|
||||
}
|
||||
}
|
||||
|
||||
export function moveTaskList(id, direction) {
|
||||
const idx = taskLists.findIndex((l) => l.id === id);
|
||||
const targetIdx = idx + direction;
|
||||
if (targetIdx < 0 || targetIdx >= taskLists.length) return;
|
||||
if (taskLists[targetIdx].builtIn) return;
|
||||
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
export function sortTaskLists(mode) {
|
||||
const builtIn = taskLists.filter((l) => l.builtIn);
|
||||
const custom = taskLists.filter((l) => !l.builtIn);
|
||||
if (mode === "alpha") {
|
||||
custom.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (mode === "date") {
|
||||
custom.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
}
|
||||
taskLists.length = 0;
|
||||
taskLists.push(...builtIn, ...custom);
|
||||
saveTaskLists();
|
||||
}
|
||||
|
||||
// BroadcastChannel for task lists
|
||||
const taskListChannel = typeof BroadcastChannel !== "undefined"
|
||||
? new BroadcastChannel("ramble_task_lists")
|
||||
: null;
|
||||
|
||||
if (taskListChannel) {
|
||||
taskListChannel.onmessage = (event) => {
|
||||
if (event.data.type === "task_lists_updated") {
|
||||
taskLists.length = 0;
|
||||
taskLists.push(...event.data.lists);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastTaskLists() {
|
||||
if (taskListChannel) {
|
||||
taskListChannel.postMessage({ type: "task_lists_updated", lists: $state.snapshot(taskLists) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Templates (persisted to localStorage) ----
|
||||
|
||||
const TEMPLATES_KEY = "ramble_templates";
|
||||
|
||||
function loadTemplates() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TEMPLATES_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [
|
||||
{ name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] },
|
||||
{ name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] },
|
||||
{ name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] },
|
||||
];
|
||||
}
|
||||
|
||||
export const templates = $state(loadTemplates());
|
||||
|
||||
export function saveTemplates() {
|
||||
try {
|
||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
||||
} catch {}
|
||||
}
|
||||
30
src/lib/utils/constants.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Timing
|
||||
export const FEEDBACK_TIMEOUT_MS = 3000;
|
||||
export const HOTKEY_FEEDBACK_MS = 1500;
|
||||
export const HISTORY_MAX_ENTRIES = 100;
|
||||
export const MAX_PCM_SAMPLES = 4_800_000; // 5 min at 16kHz
|
||||
export const SIDEBAR_MAX_TASKS = 30;
|
||||
export const CHUNK_INTERVAL_MS = 3000;
|
||||
export const MIN_CHUNK_SAMPLES = 8000;
|
||||
|
||||
// Buckets
|
||||
export const BUCKET_COLORS = {
|
||||
inbox: "text-text-tertiary",
|
||||
today: "text-accent",
|
||||
soon: "text-warning",
|
||||
later: "text-text-secondary",
|
||||
};
|
||||
|
||||
export const BUCKET_DOT_COLORS = {
|
||||
inbox: "bg-text-tertiary",
|
||||
today: "bg-accent",
|
||||
soon: "bg-warning",
|
||||
later: "bg-text-secondary",
|
||||
};
|
||||
|
||||
// Effort
|
||||
export const EFFORT_LABELS = { quick: "Quick", medium: "Medium", deep: "Deep" };
|
||||
export const EFFORT_ORDER = { quick: 1, medium: 2, deep: 3, "": 4 };
|
||||
|
||||
// Playback
|
||||
export const PLAYBACK_SPEEDS = [0.5, 1, 1.5, 2, 3, 5];
|
||||
116
src/lib/utils/export.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||
|
||||
/**
|
||||
* Export transcript in various formats.
|
||||
* @param {string} text - Plain text transcript
|
||||
* @param {Array<{start: number, end: number, text: string}>} segments - Timed segments
|
||||
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function exportTranscript(text, segments, format) {
|
||||
switch (format) {
|
||||
case "srt":
|
||||
return toSRT(segments);
|
||||
case "vtt":
|
||||
return toVTT(segments);
|
||||
case "md":
|
||||
return toMarkdown(text, segments);
|
||||
case "csv":
|
||||
return toCSV(segments);
|
||||
case "html":
|
||||
return toHTML(text, segments);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function toSRT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
return segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toVTT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
let out = "WEBVTT\n\n";
|
||||
out += segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCSV(segments) {
|
||||
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
||||
let csv = "Start,End,Text\n";
|
||||
for (const seg of segments) {
|
||||
const text = seg.text.trim().replace(/"/g, '""');
|
||||
csv += `${seg.start.toFixed(2)},${seg.end.toFixed(2)},"${text}"\n`;
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
function toHTML(text, segments) {
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ramble Transcript</title>
|
||||
<style>
|
||||
body { font-family: "DM Sans", system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; color: #1a1816; line-height: 1.7; }
|
||||
h1 { font-family: "Instrument Serif", Georgia, serif; font-style: italic; }
|
||||
.meta { color: #9a9486; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.segment { display: flex; gap: 1rem; padding: 0.4rem 0; }
|
||||
.timestamp { color: #9a9486; font-size: 0.8rem; min-width: 3rem; font-variant-numeric: tabular-nums; padding-top: 0.15rem; }
|
||||
.text { flex: 1; }
|
||||
.starred { border-left: 3px solid #e8a87c; padding-left: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Transcript</h1>
|
||||
<p class="meta">${new Date().toLocaleDateString("en-GB")}`;
|
||||
if (segments?.length > 0) {
|
||||
const dur = segments[segments.length - 1].end;
|
||||
html += ` · ${Math.round(dur)}s`;
|
||||
}
|
||||
html += `</p>\n`;
|
||||
|
||||
if (segments?.length > 0) {
|
||||
for (const seg of segments) {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
const starClass = seg.starred ? ' starred' : '';
|
||||
html += `<div class="segment${starClass}"><span class="timestamp">${pad(mins)}:${pad(secs)}</span><span class="text">${seg.text.trim()}</span></div>\n`;
|
||||
}
|
||||
} else {
|
||||
html += `<p>${text.replace(/\n/g, "<br>")}</p>`;
|
||||
}
|
||||
html += `</body></html>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function toMarkdown(text, segments) {
|
||||
let md = `# Transcription\n\n`;
|
||||
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
||||
if (segments && segments.length > 0) {
|
||||
const duration = segments[segments.length - 1].end;
|
||||
md += `**Duration:** ${Math.round(duration)}s\n`;
|
||||
}
|
||||
md += `\n---\n\n`;
|
||||
|
||||
if (segments && segments.length > 0) {
|
||||
segments.forEach((seg) => {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
md += `[${pad(mins)}:${pad(secs)}] ${seg.text.trim()}\n\n`;
|
||||
});
|
||||
} else {
|
||||
md += text;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
127
src/lib/utils/taskExtractor.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Rule-based task extraction from transcripts.
|
||||
* Identifies action items from natural speech patterns.
|
||||
*/
|
||||
|
||||
// Action verbs that typically start task sentences
|
||||
const ACTION_VERBS = [
|
||||
"call", "email", "send", "write", "build", "fix", "update", "check",
|
||||
"review", "schedule", "book", "create", "set up", "follow up", "organise",
|
||||
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
|
||||
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
|
||||
"move", "add", "remove", "delete", "install", "configure", "contact",
|
||||
"message", "tell", "ask", "invite", "remind", "finish", "complete",
|
||||
"start", "begin", "plan", "design", "implement", "refactor",
|
||||
];
|
||||
|
||||
// Phrases that signal a task
|
||||
const TASK_PHRASES = [
|
||||
"need to", "needs to", "should", "must", "have to", "has to",
|
||||
"want to", "going to", "gonna", "got to", "gotta",
|
||||
"don't forget to", "remember to", "make sure to", "make sure we",
|
||||
"let's", "we should", "i should", "we need to", "i need to",
|
||||
"we have to", "i have to", "we must", "i must",
|
||||
];
|
||||
|
||||
// Explicit markers (user intentionally marks a task)
|
||||
const EXPLICIT_MARKERS = [
|
||||
"action:", "action item:", "todo:", "task:", "to do:",
|
||||
"action item", "follow up:", "follow-up:",
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract tasks from a transcript string.
|
||||
* Returns an array of { text, confidence } objects.
|
||||
*/
|
||||
export function extractTasks(transcript) {
|
||||
if (!transcript || !transcript.trim()) return [];
|
||||
|
||||
const tasks = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Split into sentences (rough but effective for speech)
|
||||
const sentences = splitSentences(transcript);
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const trimmed = sentence.trim();
|
||||
if (trimmed.length < 5) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
let matched = false;
|
||||
let confidence = 0;
|
||||
|
||||
// Check explicit markers (highest confidence)
|
||||
for (const marker of EXPLICIT_MARKERS) {
|
||||
if (lower.startsWith(marker) || lower.includes(marker)) {
|
||||
const taskText = extractAfterMarker(trimmed, marker);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.95);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check task phrases (high confidence)
|
||||
for (const phrase of TASK_PHRASES) {
|
||||
const idx = lower.indexOf(phrase);
|
||||
if (idx >= 0) {
|
||||
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.8);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check if sentence starts with an action verb (medium confidence)
|
||||
const firstWord = lower.split(/\s+/)[0];
|
||||
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
|
||||
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
|
||||
addTask(tasks, seen, capitalise(trimmed), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function splitSentences(text) {
|
||||
// Split on sentence-ending punctuation, newlines, or common speech breaks
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+|\n+/)
|
||||
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
|
||||
.filter((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
function extractAfterMarker(sentence, marker) {
|
||||
const lower = sentence.toLowerCase();
|
||||
const idx = lower.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
|
||||
}
|
||||
|
||||
function extractTaskFromPhrase(sentence, phrase, idx) {
|
||||
// Extract "need to X" → "X"
|
||||
const afterPhrase = sentence.slice(idx + phrase.length).trim();
|
||||
if (afterPhrase) return afterPhrase;
|
||||
return null;
|
||||
}
|
||||
|
||||
function capitalise(text) {
|
||||
if (!text) return text;
|
||||
// Clean up leading punctuation/whitespace
|
||||
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
|
||||
if (!cleaned) return text;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
function addTask(tasks, seen, text, confidence) {
|
||||
// Remove trailing punctuation for dedup
|
||||
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
|
||||
}
|
||||
46
src/lib/utils/time.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Pad number to 2 digits */
|
||||
export function pad(n) {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Format seconds as M:SS (e.g. 1:05) */
|
||||
export function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/** Format seconds as human duration (e.g. 2m 30s) */
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
||||
export function formatTimestamp(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
||||
" " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
||||
export function formatTimeSRT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)},${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
||||
export function formatTimeVTT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||
5
src/routes/+layout.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
98
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
import { onDestroy } from "svelte";
|
||||
import Sidebar from "$lib/Sidebar.svelte";
|
||||
import TaskSidebar from "$lib/components/TaskSidebar.svelte";
|
||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||
import { page, settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Recording indicator dot (follows mouse)
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
function handleMouseMove(e) {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
}
|
||||
|
||||
// Theme application
|
||||
$effect(() => {
|
||||
const theme = settings.theme;
|
||||
if (theme === "Light") {
|
||||
document.documentElement.classList.add("light");
|
||||
return;
|
||||
}
|
||||
if (theme === "Dark") {
|
||||
document.documentElement.classList.remove("light");
|
||||
return;
|
||||
}
|
||||
// System mode
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const apply = () => document.documentElement.classList.toggle("light", mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
});
|
||||
|
||||
// Global hotkey registration
|
||||
let registeredHotkey = null;
|
||||
|
||||
async function registerGlobalHotkey(hotkey) {
|
||||
try {
|
||||
const mod = await import("@tauri-apps/plugin-global-shortcut");
|
||||
if (registeredHotkey) {
|
||||
await mod.unregister(registeredHotkey).catch(() => {});
|
||||
}
|
||||
await mod.register(hotkey, () => {
|
||||
if (page.current !== "dictation") page.current = "dictation";
|
||||
requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new CustomEvent("ramble:toggle-recording"));
|
||||
});
|
||||
});
|
||||
registeredHotkey = hotkey;
|
||||
} catch (err) {
|
||||
console.error("Hotkey registration failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
registerGlobalHotkey(settings.globalHotkey);
|
||||
});
|
||||
|
||||
// Apply font size setting as CSS variable
|
||||
$effect(() => {
|
||||
document.documentElement.style.setProperty("--font-size-transcript", `${settings.fontSize}px`);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (registeredHotkey) {
|
||||
import("@tauri-apps/plugin-global-shortcut")
|
||||
.then((mod) => mod.unregister(registeredHotkey))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onmousemove={handleMouseMove} />
|
||||
|
||||
{#if page.recording}
|
||||
<div
|
||||
class="fixed w-3 h-3 rounded-full bg-danger animate-pulse-soft pointer-events-none"
|
||||
style="left: {mouseX + 18}px; top: {mouseY + 18}px; z-index: 100;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col h-screen w-screen overflow-hidden grain">
|
||||
<Titlebar />
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<Sidebar />
|
||||
<div class="flex-1 overflow-hidden bg-bg">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if page.taskSidebarOpen}
|
||||
<TaskSidebar />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
29
src/routes/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { page } from "$lib/stores/page.svelte.js";
|
||||
import DictationPage from "$lib/pages/DictationPage.svelte";
|
||||
import FilesPage from "$lib/pages/FilesPage.svelte";
|
||||
import TasksPage from "$lib/pages/TasksPage.svelte";
|
||||
import HistoryPage from "$lib/pages/HistoryPage.svelte";
|
||||
import SettingsPage from "$lib/pages/SettingsPage.svelte";
|
||||
|
||||
// Redirect legacy "profiles" page to settings
|
||||
$effect(() => {
|
||||
if (page.current === "profiles") page.current = "settings";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full overflow-hidden">
|
||||
{#key page.current}
|
||||
{#if page.current === "dictation"}
|
||||
<DictationPage />
|
||||
{:else if page.current === "files"}
|
||||
<FilesPage />
|
||||
{:else if page.current === "tasks"}
|
||||
<TasksPage />
|
||||
{:else if page.current === "history"}
|
||||
<HistoryPage />
|
||||
{:else if page.current === "settings"}
|
||||
<SettingsPage />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
71
src/routes/float/+layout@.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import "../../app.css";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
let glowing = $state(false);
|
||||
let unlistenFocus = null;
|
||||
let quickAddEl = $state(null);
|
||||
|
||||
// Theme application (float window has its own DOM)
|
||||
$effect(() => {
|
||||
const theme = settings.theme;
|
||||
if (theme === "Light") {
|
||||
document.documentElement.classList.add("light");
|
||||
return;
|
||||
}
|
||||
if (theme === "Dark") {
|
||||
document.documentElement.classList.remove("light");
|
||||
return;
|
||||
}
|
||||
// System mode
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const apply = () => document.documentElement.classList.toggle("light", mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
});
|
||||
|
||||
// Listen for settings changes from main window
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "ramble_settings" && e.newValue) {
|
||||
try {
|
||||
Object.assign(settings, JSON.parse(e.newValue));
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
unlistenFocus = await listen("task-window-focus", () => {
|
||||
glowing = true;
|
||||
setTimeout(() => { glowing = false; }, 700);
|
||||
// Auto-focus quick-add input
|
||||
const input = document.querySelector("[data-quick-add]");
|
||||
if (input) input.focus();
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlistenFocus) unlistenFocus();
|
||||
});
|
||||
|
||||
// Escape to close
|
||||
function handleKeydown(e) {
|
||||
if (e.key === "Escape") {
|
||||
getCurrentWindow().close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter {glowing ? 'animate-glow-pulse' : ''}">
|
||||
{@render children()}
|
||||
</div>
|
||||
460
src/routes/float/+page.svelte
Normal file
@@ -0,0 +1,460 @@
|
||||
<script>
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
tasks, addTask, completeTask, uncompleteTask, deleteTask,
|
||||
taskLists, addTaskList, renameTaskList, deleteTaskList, moveTaskList, sortTaskLists,
|
||||
moveTaskToList, moveTaskListToProfile, profiles,
|
||||
} from "$lib/stores/page.svelte.js";
|
||||
|
||||
let quickInput = $state("");
|
||||
let pinned = $state(true);
|
||||
let activeListId = $state("all");
|
||||
let showCompleted = $state(false);
|
||||
let newListName = $state("");
|
||||
let showNewList = $state(false);
|
||||
let contextMenuListId = $state(null);
|
||||
let editingListId = $state(null);
|
||||
let editingName = $state("");
|
||||
let showSortMenu = $state(false);
|
||||
let draggingTaskId = $state(null);
|
||||
let dropHighlightId = $state(null);
|
||||
|
||||
// Filtered tasks for active list
|
||||
let tasksForActiveList = $derived.by(() => {
|
||||
const active = tasks.filter((t) => !t.done);
|
||||
if (activeListId === "all") return active;
|
||||
if (activeListId === "inbox") return active.filter((t) => !t.listId);
|
||||
return active.filter((t) => t.listId === activeListId);
|
||||
});
|
||||
|
||||
let completedForActiveList = $derived.by(() => {
|
||||
const done = tasks.filter((t) => t.done);
|
||||
if (activeListId === "all") return done.slice(0, 10);
|
||||
if (activeListId === "inbox") return done.filter((t) => !t.listId).slice(0, 10);
|
||||
return done.filter((t) => t.listId === activeListId).slice(0, 10);
|
||||
});
|
||||
|
||||
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(
|
||||
taskLists.find((l) => l.id === activeListId)?.name || "Tasks"
|
||||
);
|
||||
|
||||
// Split lists into built-in and custom for section divider
|
||||
let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
|
||||
let customLists = $derived(taskLists.filter((l) => !l.builtIn));
|
||||
|
||||
function handleQuickAdd(e) {
|
||||
if (e.key === "Enter" && quickInput.trim()) {
|
||||
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
||||
addTask({ text: quickInput.trim(), bucket: "inbox", listId });
|
||||
quickInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
if (e.target.closest("input")) return;
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
async function togglePin() {
|
||||
pinned = !pinned;
|
||||
await getCurrentWindow().setAlwaysOnTop(pinned);
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
getCurrentWindow().close();
|
||||
}
|
||||
|
||||
// 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 handleRenameKey(e) {
|
||||
if (e.key === "Enter") finishRenaming();
|
||||
if (e.key === "Escape") { 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;
|
||||
}
|
||||
|
||||
// Drag-and-drop: tasks between lists
|
||||
function handleTaskDragStart(e, taskId) {
|
||||
draggingTaskId = taskId;
|
||||
e.dataTransfer.setData("text/plain", taskId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
function handleTaskDragEnd() {
|
||||
draggingTaskId = null;
|
||||
dropHighlightId = null;
|
||||
}
|
||||
|
||||
function handleListDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
function handleListDragEnter(e, listId) {
|
||||
e.preventDefault();
|
||||
dropHighlightId = listId;
|
||||
}
|
||||
|
||||
function handleListDragLeave(e, listId) {
|
||||
if (dropHighlightId === listId) dropHighlightId = null;
|
||||
}
|
||||
|
||||
function handleListDrop(e, listId) {
|
||||
e.preventDefault();
|
||||
dropHighlightId = null;
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (taskId) {
|
||||
moveTaskToList(taskId, listId);
|
||||
}
|
||||
}
|
||||
|
||||
// Close context menu on outside click
|
||||
function handleWindowClick() {
|
||||
if (contextMenuListId) contextMenuListId = null;
|
||||
if (showSortMenu) showSortMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="flex flex-col h-full bg-bg">
|
||||
<!-- Drag handle with title -->
|
||||
<div
|
||||
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region>
|
||||
Ramble - To-do
|
||||
</span>
|
||||
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
|
||||
<!-- Pin button -->
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center rounded-md
|
||||
{pinned ? 'bg-accent/15 text-accent' : 'text-text-tertiary hover:bg-hover hover:text-text-secondary'}"
|
||||
onclick={togglePin}
|
||||
title={pinned ? "Pinned on top (click to unpin)" : "Pin on top"}
|
||||
>
|
||||
{#if pinned}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2Z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center text-text-tertiary hover:text-danger rounded-md hover:bg-hover ml-1"
|
||||
onclick={closeWindow}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Single-column content -->
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- List selector pills -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-border-subtle overflow-x-auto">
|
||||
{#each builtInLists as list (list.id)}
|
||||
<button
|
||||
class="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] whitespace-nowrap flex-shrink-0
|
||||
{activeListId === list.id
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}
|
||||
{dropHighlightId === list.id ? 'ring-1 ring-accent scale-105' : ''}"
|
||||
onclick={() => { activeListId = list.id; contextMenuListId = null; }}
|
||||
ondragover={handleListDragOver}
|
||||
ondragenter={(e) => handleListDragEnter(e, list.id)}
|
||||
ondragleave={(e) => handleListDragLeave(e, list.id)}
|
||||
ondrop={(e) => handleListDrop(e, list.id)}
|
||||
>
|
||||
{list.name}
|
||||
{#if countForList(list.id) > 0}
|
||||
<span class="text-[9px] px-1 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{countForList(list.id)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Divider between built-in and custom lists -->
|
||||
{#if customLists.length > 0}
|
||||
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
{#each customLists as list (list.id)}
|
||||
{#if editingListId === list.id}
|
||||
<input
|
||||
type="text"
|
||||
class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text
|
||||
focus:outline-none w-[100px]"
|
||||
bind:value={editingName}
|
||||
onkeydown={handleRenameKey}
|
||||
onblur={finishRenaming}
|
||||
data-no-transition
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
class="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] whitespace-nowrap
|
||||
{activeListId === list.id
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}
|
||||
{dropHighlightId === list.id ? 'ring-1 ring-accent scale-105' : ''}"
|
||||
onclick={() => { activeListId = list.id; contextMenuListId = null; }}
|
||||
ondblclick={() => startRenaming(list)}
|
||||
oncontextmenu={(e) => toggleContextMenu(e, list.id)}
|
||||
ondragover={handleListDragOver}
|
||||
ondragenter={(e) => handleListDragEnter(e, list.id)}
|
||||
ondragleave={(e) => handleListDragLeave(e, list.id)}
|
||||
ondrop={(e) => handleListDrop(e, list.id)}
|
||||
>
|
||||
{#if list.profileId}
|
||||
<svg class="w-2.5 h-2.5 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>
|
||||
{/if}
|
||||
{list.name}
|
||||
{#if countForList(list.id) > 0}
|
||||
<span class="text-[9px] px-1 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{countForList(list.id)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Context menu -->
|
||||
{#if contextMenuListId === list.id}
|
||||
<div class="absolute left-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]"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { moveTaskList(list.id, -1); contextMenuListId = null; }}
|
||||
>Move left</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { moveTaskList(list.id, 1); contextMenuListId = null; }}
|
||||
>Move right</button>
|
||||
{#if profiles.length > 0}
|
||||
<div class="my-1 h-px bg-border-subtle"></div>
|
||||
<p class="px-3 py-0.5 text-[9px] text-text-tertiary uppercase tracking-wider">Move to profile</p>
|
||||
{#each profiles as profile}
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] hover:bg-hover
|
||||
{list.profileId === profile.name ? 'text-accent' : 'text-text-secondary hover:text-text'}"
|
||||
onclick={() => { moveTaskListToProfile(list.id, profile.name); contextMenuListId = null; }}
|
||||
>{profile.name}</button>
|
||||
{/each}
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { moveTaskListToProfile(list.id, null); contextMenuListId = null; }}
|
||||
>None (unlink)</button>
|
||||
{/if}
|
||||
<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}
|
||||
|
||||
<!-- New list button -->
|
||||
{#if showNewList}
|
||||
<input
|
||||
type="text"
|
||||
class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0"
|
||||
placeholder="List name..."
|
||||
bind:value={newListName}
|
||||
onkeydown={handleCreateList}
|
||||
onblur={() => { showNewList = false; newListName = ""; }}
|
||||
data-no-transition
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="px-2 py-1 rounded-lg text-[11px] text-accent hover:bg-accent/10 whitespace-nowrap flex-shrink-0"
|
||||
onclick={(e) => { e.stopPropagation(); showNewList = true; }}
|
||||
>+</button>
|
||||
{/if}
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="relative flex-shrink-0 ml-auto">
|
||||
<button
|
||||
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
|
||||
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</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-[110px]"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }}
|
||||
>Alphabetical</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||
onclick={() => { sortTaskLists("date"); showSortMenu = false; }}
|
||||
>By date created</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick add (top — capture first) -->
|
||||
<div class="px-3 py-2 border-b border-border-subtle">
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Add task to {activeListName}..."
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
data-no-transition
|
||||
data-quick-add
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<div class="flex-1 overflow-y-auto px-3 py-2 min-h-0">
|
||||
{#if tasksForActiveList.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full opacity-60">
|
||||
<p class="text-[11px] text-text-tertiary text-center">No active tasks</p>
|
||||
<p class="text-[10px] text-text-tertiary mt-1">Type above or drag tasks here</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each tasksForActiveList as task (task.id)}
|
||||
<div
|
||||
class="group flex items-start gap-2.5 px-2 py-2 rounded-lg hover:bg-hover cursor-grab active:cursor-grabbing
|
||||
{draggingTaskId === task.id ? 'opacity-40' : ''}"
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleTaskDragStart(e, task.id)}
|
||||
ondragend={handleTaskDragEnd}
|
||||
>
|
||||
<button
|
||||
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"
|
||||
onclick={() => completeTask(task.id)}
|
||||
aria-label="Complete"
|
||||
></button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[12px] text-text leading-[1.4]">{task.text}</p>
|
||||
{#if task.createdAt}
|
||||
<p class="text-[10px] text-text-tertiary mt-0.5">
|
||||
{new Date(task.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" 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 -->
|
||||
{#if completedForActiveList.length > 0}
|
||||
<div class="mt-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-[10px] text-text-tertiary hover:text-text-secondary mb-1.5"
|
||||
onclick={() => showCompleted = !showCompleted}
|
||||
>
|
||||
<svg class="w-2.5 h-2.5 transition-transform {showCompleted ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
Done ({completedForActiveList.length})
|
||||
</button>
|
||||
{#if showCompleted}
|
||||
<div class="flex flex-col gap-0.5 animate-fade-in">
|
||||
{#each completedForActiveList as task (task.id)}
|
||||
<div class="group flex items-start gap-2.5 px-2 py-1.5 rounded-lg opacity-50 hover:opacity-70">
|
||||
<button
|
||||
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 animate-scale-pop"
|
||||
onclick={() => uncompleteTask(task.id)}
|
||||
aria-label="Uncomplete"
|
||||
>
|
||||
<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>
|
||||
<p class="text-[12px] text-text-secondary line-through flex-1">{task.text}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
48
src/routes/viewer/+layout@.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
import "../../app.css";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Theme application (viewer window has its own DOM)
|
||||
$effect(() => {
|
||||
const theme = settings.theme;
|
||||
if (theme === "Light") {
|
||||
document.documentElement.classList.add("light");
|
||||
return;
|
||||
}
|
||||
if (theme === "Dark") {
|
||||
document.documentElement.classList.remove("light");
|
||||
return;
|
||||
}
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const apply = () => document.documentElement.classList.toggle("light", mq.matches);
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
});
|
||||
|
||||
// Sync settings from main window
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "ramble_settings" && e.newValue) {
|
||||
try { Object.assign(settings, JSON.parse(e.newValue)); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Escape to close
|
||||
function handleKeydown(e) {
|
||||
if (e.key === "Escape") {
|
||||
getCurrentWindow().close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter">
|
||||
{@render children()}
|
||||
</div>
|
||||
452
src/routes/viewer/+page.svelte
Normal file
@@ -0,0 +1,452 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||
|
||||
let item = $state(null);
|
||||
let audioEl = $state(null);
|
||||
let playing = $state(false);
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let playbackRate = $state(1);
|
||||
let searchQuery = $state("");
|
||||
let activeSegmentIdx = $state(-1);
|
||||
let editingIdx = $state(-1);
|
||||
let editingText = $state("");
|
||||
let showTimestamps = $state(true);
|
||||
let showStarredOnly = $state(false);
|
||||
let animFrameId = null;
|
||||
let segmentRefs = [];
|
||||
|
||||
|
||||
// Load item data from localStorage (set by HistoryPage before opening this window)
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("ramble_viewer_item");
|
||||
if (raw) {
|
||||
item = JSON.parse(raw);
|
||||
if (item.audioPath) {
|
||||
const src = convertFileSrc(item.audioPath);
|
||||
const audio = new Audio(src);
|
||||
audio.onloadedmetadata = () => { duration = audio.duration; };
|
||||
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
|
||||
audio.onerror = () => { playing = false; };
|
||||
audioEl = audio;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Listen for new items via storage events
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (audioEl) audioEl.pause();
|
||||
cancelAnimationFrame(animFrameId);
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
});
|
||||
|
||||
function handleStorageChange(e) {
|
||||
if (e.key === "ramble_viewer_item" && e.newValue) {
|
||||
try {
|
||||
if (audioEl) { audioEl.pause(); }
|
||||
cancelAnimationFrame(animFrameId);
|
||||
playing = false;
|
||||
currentTime = 0;
|
||||
|
||||
item = JSON.parse(e.newValue);
|
||||
if (item.audioPath) {
|
||||
const src = convertFileSrc(item.audioPath);
|
||||
const audio = new Audio(src);
|
||||
audio.onloadedmetadata = () => { duration = audio.duration; };
|
||||
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
|
||||
audioEl = audio;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!audioEl) return;
|
||||
if (playing) {
|
||||
audioEl.pause();
|
||||
playing = false;
|
||||
cancelAnimationFrame(animFrameId);
|
||||
} else {
|
||||
audioEl.play();
|
||||
playing = true;
|
||||
tickTime();
|
||||
}
|
||||
}
|
||||
|
||||
function tickTime() {
|
||||
if (audioEl) {
|
||||
currentTime = audioEl.currentTime;
|
||||
updateActiveSegment();
|
||||
if (!audioEl.paused) {
|
||||
animFrameId = requestAnimationFrame(tickTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveSegment() {
|
||||
if (!item?.segments) return;
|
||||
const t = currentTime;
|
||||
for (let i = 0; i < item.segments.length; i++) {
|
||||
const seg = item.segments[i];
|
||||
if (t >= seg.start && t < seg.end) {
|
||||
if (activeSegmentIdx !== i) {
|
||||
activeSegmentIdx = i;
|
||||
// Auto-scroll to active segment
|
||||
if (segmentRefs[i]) {
|
||||
segmentRefs[i].scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seekTo(e) {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (audioEl) {
|
||||
audioEl.currentTime = val;
|
||||
currentTime = val;
|
||||
updateActiveSegment();
|
||||
}
|
||||
}
|
||||
|
||||
function seekToTime(time) {
|
||||
if (audioEl) {
|
||||
audioEl.currentTime = time;
|
||||
currentTime = time;
|
||||
if (!playing) {
|
||||
audioEl.play();
|
||||
playing = true;
|
||||
tickTime();
|
||||
}
|
||||
updateActiveSegment();
|
||||
}
|
||||
}
|
||||
|
||||
function setSpeed(speed) {
|
||||
playbackRate = speed;
|
||||
if (audioEl) audioEl.playbackRate = speed;
|
||||
}
|
||||
|
||||
|
||||
// Search: find matching segments
|
||||
let matchingSegments = $derived.by(() => {
|
||||
if (!searchQuery.trim() || !item?.segments) return new Set();
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matches = new Set();
|
||||
item.segments.forEach((seg, i) => {
|
||||
if (seg.text.toLowerCase().includes(q)) matches.add(i);
|
||||
});
|
||||
return matches;
|
||||
});
|
||||
|
||||
let matchCount = $derived.by(() => matchingSegments.size);
|
||||
|
||||
// Highlight search matches in text
|
||||
function highlightText(text) {
|
||||
if (!searchQuery.trim()) return text;
|
||||
const q = searchQuery.trim();
|
||||
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
return text.replace(regex, '<mark class="bg-accent/25 text-text rounded px-0.5">$1</mark>');
|
||||
}
|
||||
|
||||
// Segment editing
|
||||
function startEditing(idx) {
|
||||
editingIdx = idx;
|
||||
editingText = item.segments[idx].text;
|
||||
}
|
||||
|
||||
function finishEditing() {
|
||||
if (editingIdx >= 0 && item?.segments) {
|
||||
item.segments[editingIdx].text = editingText.trim();
|
||||
// Update full text
|
||||
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
||||
saveItemToHistory();
|
||||
}
|
||||
editingIdx = -1;
|
||||
editingText = "";
|
||||
}
|
||||
|
||||
function deleteSegment(idx) {
|
||||
if (item?.segments) {
|
||||
item.segments.splice(idx, 1);
|
||||
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
||||
saveItemToHistory();
|
||||
if (editingIdx === idx) { editingIdx = -1; }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStar(idx) {
|
||||
if (item?.segments) {
|
||||
item.segments[idx].starred = !item.segments[idx].starred;
|
||||
saveItemToHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function copySegment(idx) {
|
||||
if (item?.segments?.[idx]) {
|
||||
navigator.clipboard.writeText(item.segments[idx].text.trim()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function saveItemToHistory() {
|
||||
// Persist edits back to localStorage history
|
||||
try {
|
||||
const raw = localStorage.getItem("ramble_history");
|
||||
if (raw) {
|
||||
const hist = JSON.parse(raw);
|
||||
const idx = hist.findIndex((h) => h.id === item.id);
|
||||
if (idx >= 0) {
|
||||
hist[idx] = { ...hist[idx], segments: item.segments, text: item.text };
|
||||
localStorage.setItem("ramble_history", JSON.stringify(hist));
|
||||
}
|
||||
}
|
||||
// Also update the viewer item cache
|
||||
localStorage.setItem("ramble_viewer_item", JSON.stringify(item));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Filtered segments (starred filter + search)
|
||||
let visibleSegments = $derived.by(() => {
|
||||
if (!item?.segments) return [];
|
||||
let segs = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
|
||||
if (showStarredOnly) {
|
||||
segs = segs.filter((s) => s.starred);
|
||||
}
|
||||
return segs;
|
||||
});
|
||||
|
||||
// Window drag
|
||||
function handleDragStart(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest("button")) return;
|
||||
if (e.target.closest("input")) return;
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-bg">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
||||
onmousedown={handleDragStart}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region>
|
||||
Ramble - Viewer
|
||||
</span>
|
||||
<div class="flex-1" data-tauri-drag-region></div>
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center text-text-tertiary hover:text-danger rounded-md hover:bg-hover"
|
||||
onclick={closeWindow}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if item}
|
||||
<!-- Item info -->
|
||||
<div class="px-5 pt-3 pb-2">
|
||||
<p class="text-[13px] text-text font-medium">
|
||||
{item.title || "Transcript"}
|
||||
</p>
|
||||
<p class="text-[10px] text-text-tertiary mt-0.5">
|
||||
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media controls -->
|
||||
{#if audioEl}
|
||||
<div class="flex items-center gap-3 px-5 py-2 border-b border-border-subtle">
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0
|
||||
{playing ? 'bg-accent text-white' : 'bg-accent/15 text-accent hover:bg-accent/25'}"
|
||||
onclick={togglePlay}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
>
|
||||
{#if playing}
|
||||
<svg class="w-4 h-4" 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-4 h-4 ml-0.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="text-[11px] text-text-tertiary tabular-nums min-w-[70px]">
|
||||
{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
|
||||
/>
|
||||
|
||||
<!-- Speed pills -->
|
||||
<div class="flex gap-0.5 flex-shrink-0">
|
||||
{#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={() => setSpeed(speed)}
|
||||
>{speed}x</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search bar + view controls -->
|
||||
<div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle">
|
||||
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1">
|
||||
<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 transcript..."
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<span class="text-[10px] text-text-tertiary">{matchCount} matches</span>
|
||||
<button class="text-[10px] text-text-tertiary hover:text-text" onclick={() => searchQuery = ""}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Compact mode toggle -->
|
||||
<button
|
||||
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
|
||||
{showTimestamps ? 'text-text-tertiary hover:text-text-secondary' : 'text-accent bg-accent/10'}"
|
||||
onclick={() => showTimestamps = !showTimestamps}
|
||||
title={showTimestamps ? "Hide timestamps (compact)" : "Show timestamps"}
|
||||
>Compact</button>
|
||||
<!-- Starred filter -->
|
||||
<button
|
||||
class="text-[10px] px-2 py-1 rounded-lg flex-shrink-0
|
||||
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary'}"
|
||||
onclick={() => showStarredOnly = !showStarredOnly}
|
||||
title={showStarredOnly ? "Show all segments" : "Show starred only"}
|
||||
>Starred</button>
|
||||
</div>
|
||||
|
||||
<!-- Transcript segments -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
|
||||
{#if item.segments && item.segments.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each visibleSegments as seg (seg._idx)}
|
||||
<div
|
||||
bind:this={segmentRefs[seg._idx]}
|
||||
class="group flex gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors
|
||||
{activeSegmentIdx === seg._idx
|
||||
? 'bg-accent/10 border-l-2 border-accent'
|
||||
: matchingSegments.has(seg._idx)
|
||||
? 'bg-warning/5 border-l-2 border-warning/40'
|
||||
: 'border-l-2 border-transparent hover:bg-hover'}"
|
||||
onclick={() => seekToTime(seg.start)}
|
||||
ondblclick={() => startEditing(seg._idx)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === "Enter" && editingIdx !== seg._idx) seekToTime(seg.start); }}
|
||||
>
|
||||
<!-- Star -->
|
||||
<button
|
||||
class="mt-0.5 flex-shrink-0 {seg.starred ? 'text-accent' : 'text-text-tertiary opacity-0 group-hover:opacity-100'}"
|
||||
onclick={(e) => { e.stopPropagation(); toggleStar(seg._idx); }}
|
||||
aria-label={seg.starred ? "Unstar" : "Star"}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill={seg.starred ? "currentColor" : "none"} stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Timestamp -->
|
||||
{#if showTimestamps}
|
||||
<span class="text-[10px] text-text-tertiary tabular-nums flex-shrink-0 mt-0.5 min-w-[36px]">
|
||||
{formatTime(seg.start)}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Text (editable or display) -->
|
||||
{#if editingIdx === seg._idx}
|
||||
<textarea
|
||||
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
|
||||
leading-relaxed resize-none focus:outline-none min-h-[40px]"
|
||||
bind:value={editingText}
|
||||
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finishEditing(); } if (e.key === "Escape") { editingIdx = -1; } }}
|
||||
onblur={finishEditing}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-no-transition
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<p class="text-[13px] text-text leading-relaxed flex-1">
|
||||
{#if searchQuery.trim()}
|
||||
{@html highlightText(seg.text.trim())}
|
||||
{:else}
|
||||
{seg.text.trim()}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Actions (hover reveal) -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 flex-shrink-0">
|
||||
<button
|
||||
class="text-text-tertiary hover:text-accent"
|
||||
onclick={(e) => { e.stopPropagation(); copySegment(seg._idx); }}
|
||||
title="Copy segment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-danger"
|
||||
onclick={(e) => { e.stopPropagation(); deleteSegment(seg._idx); }}
|
||||
title="Delete segment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No segments — show full text -->
|
||||
<p class="text-[13px] text-text leading-relaxed whitespace-pre-wrap">{item.text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<p class="text-[13px] text-text-tertiary">No transcript loaded</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
BIN
static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
43
static/pcm-processor.js
Normal file
@@ -0,0 +1,43 @@
|
||||
class PcmProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = [];
|
||||
// sampleRate is a global in AudioWorkletProcessor scope
|
||||
this.ratio = sampleRate / 16000;
|
||||
this.needsResample = Math.abs(this.ratio - 1.0) > 0.01;
|
||||
this.resamplePos = 0;
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
|
||||
const samples = input[0]; // First channel (mono)
|
||||
if (!samples) return true;
|
||||
|
||||
if (this.needsResample) {
|
||||
// Simple downsampling to 16kHz
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
this.resamplePos += 1;
|
||||
if (this.resamplePos >= this.ratio) {
|
||||
this.buffer.push(samples[i]);
|
||||
this.resamplePos -= this.ratio;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
this.buffer.push(samples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Send every ~0.5 seconds at 16kHz = 8000 samples
|
||||
if (this.buffer.length >= 8000) {
|
||||
this.port.postMessage({ type: "pcm", samples: this.buffer });
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("pcm-processor", PcmProcessor);
|
||||
1
static/svelte.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
6
static/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
static/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
12
svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
27
vite.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [sveltekit(), tailwindcss()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||