Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f509951c | ||
|
|
f61bcf1cc3 | ||
|
|
52ccd7ee03 | ||
|
|
d266de942a |
68
README.md
68
README.md
@@ -7,14 +7,15 @@
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
|
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
|
||||||
|
* **📅 Microsoft 365 Integration**:
|
||||||
|
* **Upcoming Meetings**: View your daily schedule and join with **one click**.
|
||||||
|
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
|
||||||
|
* **💾 Persistent History**: Automatically saves all transcripts and summaries to disk. Search and review past meetings anytime.
|
||||||
|
* **✉️ Email Summaries**: Send professional, formatted HTML summaries (with preview) directly to attendees via your own SMTP server.
|
||||||
* **🧠 Powered by Infomaniak AI**:
|
* **🧠 Powered by Infomaniak AI**:
|
||||||
* **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps** (e.g., `[00:12]`).
|
* **Precision Transcription**: Standard-compliant formatting with **second-by-second timestamps**.
|
||||||
* **Smart Summaries**: Uses advanced LLMs (Mixtral, Llama 3) to create actionable meeting notes.
|
* **Smart Summaries**: Uses **Smart Templates** to automatically select the best format (Business Protocol vs. 1:1) based on meeting content.
|
||||||
* **📝 Professional Templates**: Comes with 3 built-in expert prompts:
|
* **🔇 Smart VAD**: Automatically filters out silence and stops recording when you stop talking.
|
||||||
* **Meeting Protocol**: For general business meetings.
|
|
||||||
* **1:1 / Jour Fixe**: For confidential personnel discussions.
|
|
||||||
* **Client Meeting**: For official, client-ready documentation.
|
|
||||||
* **💾 Auto-Save**: All recordings and summaries are saved locally to your history.
|
|
||||||
* **🔒 Privacy-First**: Data is processed securely via your own Infomaniak API keys.
|
* **🔒 Privacy-First**: Data is processed securely via your own Infomaniak API keys.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -34,25 +35,15 @@
|
|||||||
|
|
||||||
## 🎧 Recording System Audio (Teams, Zoom, etc.)
|
## 🎧 Recording System Audio (Teams, Zoom, etc.)
|
||||||
|
|
||||||
To record clear meeting audio from other applications, you need a "virtual cable". We recommend **BlackHole 2ch**.
|
We've made this easy! Hearbit AI includes a built-in helper to set up your audio devices.
|
||||||
|
|
||||||
1. **Install BlackHole**: Download and install [BlackHole 2ch](https://existential.audio/blackhole/).
|
1. **Open Audio MIDI Setup**: Click the "Open Audio MIDI Setup" button in the recorder view.
|
||||||
2. **Create a Multi-Output Device** (So you can hear the audio too!):
|
2. **Create "Hearbit Audio" Device**:
|
||||||
* Open **Audio MIDI Setup** on your Mac.
|
* If you don't have a virtual device, click **"🪄 Create Hearbit Audio Device"** in the app (appears in Meeting mode if no device is found).
|
||||||
* Create a "Multi-Output Device".
|
* This will automatically configure a Multi-Output Device so you can record and hear at the same time.
|
||||||
* Select both **BlackHole 2ch** AND your **Headphones/Speakers**.
|
3. **Select "Hearbit Audio" in Teams/Zoom**:
|
||||||
* *Tip: Use this Multi-Output Device as your SPEAKER in Teams/Zoom.*
|
* In your meeting app settings (Teams/Zoom), set your **Speaker** to **Hearbit Audio**.
|
||||||
|
* In Hearbit AI, select **Hearbit Audio** (or BlackHole) as your input.
|
||||||

|
|
||||||
|
|
||||||
3. **Create Aggregate Device (Optional)**:
|
|
||||||
* If you want to record BOTH your Microphone and System Audio, create an **Aggregate Device**.
|
|
||||||
* Select **BlackHole 2ch** AND your **Microphone**.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
4. **Select Input in Hearbit AI**:
|
|
||||||
* In Hearbit AI, select **BlackHole 2ch** (or your new Aggregate Device) as the **Input Device**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -61,20 +52,41 @@ To record clear meeting audio from other applications, you need a "virtual cable
|
|||||||
1. **Configuration**:
|
1. **Configuration**:
|
||||||
* Click the **Settings** (gear icon).
|
* Click the **Settings** (gear icon).
|
||||||
* Enter your **Infomaniak API Key** and **Product ID**.
|
* Enter your **Infomaniak API Key** and **Product ID**.
|
||||||
* (Optional) Customize where recordings are saved.
|
|
||||||
|
|
||||||
2. **Recording**:
|
2. **Connect M365 (Optional)**:
|
||||||
|
* Copy the **Application (client) ID**.
|
||||||
|
* Click the **Meetings** tab.
|
||||||
|
* Enter your **Client ID** and click "Connect".
|
||||||
|
* Proceed with MS login.
|
||||||
|
* View your upcoming meetings.
|
||||||
|
|
||||||
|
3. **Recording**:
|
||||||
* Choose your **Template** (e.g., "Meeting Protocol").
|
* Choose your **Template** (e.g., "Meeting Protocol").
|
||||||
* Select your **Input Device**.
|
* Select your **Input Device**.
|
||||||
* Click **Start Recording**.
|
* Click **Start Recording**.
|
||||||
|
|
||||||
3. **Processing**:
|
4. **Processing**:
|
||||||
* Click **Stop** when finished.
|
* Click **Stop** when finished.
|
||||||
* The app will transcribe the audio (with timestamps!) and generate a summary based on your selected template.
|
* The app will transcribe the audio (with timestamps!) and generate a summary based on your selected template.
|
||||||
* You will be automatically taken to the **Transcription** tab to review the results.
|
* You will be automatically taken to the **Transcription** tab to review the results.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ❓ Troubleshooting
|
||||||
|
|
||||||
|
### "Hearbit AI is damaged and can't be opened"
|
||||||
|
This is a standard macOS warning for apps not signed with an Apple Developer Certificate. To fix it:
|
||||||
|
|
||||||
|
1. Open **Terminal**.
|
||||||
|
2. Run the following command:
|
||||||
|
```bash
|
||||||
|
sudo xattr -cr /Applications/Hearbit\ AI.app
|
||||||
|
```
|
||||||
|
3. Enter your password.
|
||||||
|
4. Open the app again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
Built with **Tauri**, **React**, and **TypeScript**.
|
Built with **Tauri**, **React**, and **TypeScript**.
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -2086,6 +2087,15 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
|
"version": "2.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||||
|
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "hearbit-ai",
|
"name": "hearbit-ai",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|||||||
1509
src-tauri/Cargo.lock
generated
1509
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hearbit-ai"
|
name = "hearbit-ai"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -26,5 +26,13 @@ serde_json = "1.0"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
cpal = "0.17.1"
|
cpal = "0.17.1"
|
||||||
hound = "3.5.1"
|
hound = "3.5.1"
|
||||||
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
|
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||||
tokio = { version = "1.40.0", features = ["full"] }
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
|
tauri-plugin-fs = "2.4.5"
|
||||||
|
voice_activity_detector = "0.2.1"
|
||||||
|
rubato = "0.14.1"
|
||||||
|
tauri-plugin-oauth = "2.0.0"
|
||||||
|
oauth2 = "4.4"
|
||||||
|
url = "2.5"
|
||||||
|
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
|
||||||
|
tauri-plugin-log = "2.0.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default"
|
"dialog:default",
|
||||||
|
"fs:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
182
src-tauri/resources/create_hearbit_audio.swift
Normal file
182
src-tauri/resources/create_hearbit_audio.swift
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
import Foundation
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
|
// Extensions and Helpers
|
||||||
|
extension Int32 {
|
||||||
|
var fourCC: String {
|
||||||
|
let utf16 = [
|
||||||
|
UInt16((self >> 24) & 0xFF),
|
||||||
|
UInt16((self >> 16) & 0xFF),
|
||||||
|
UInt16((self >> 8) & 0xFF),
|
||||||
|
UInt16(self & 0xFF)
|
||||||
|
]
|
||||||
|
return String(utf16CodeUnits: utf16, count: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safer Property Getter
|
||||||
|
func getPropertyData<T>(objectID: AudioObjectID, selector: AudioObjectPropertySelector, initialValue: T) -> T? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var size = UInt32(MemoryLayout<T>.size)
|
||||||
|
var value = initialValue
|
||||||
|
|
||||||
|
let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value)
|
||||||
|
if status == noErr {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CFString Helper
|
||||||
|
func getStringProperty(objectID: AudioObjectID, selector: AudioObjectPropertySelector) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
// CFStringRef is just a pointer, so size of Optional<Unmanaged<CFString>> is pointer size
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
var value: Unmanaged<CFString>?
|
||||||
|
|
||||||
|
let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value)
|
||||||
|
if status == noErr, let existingValue = value {
|
||||||
|
return existingValue.takeRetainedValue() as String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDeviceByName(_ name: String) -> AudioObjectID? {
|
||||||
|
// System Object is 1
|
||||||
|
let systemID = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
|
||||||
|
// Get all devices
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil }
|
||||||
|
|
||||||
|
let count = Int(size) / MemoryLayout<AudioObjectID>.size
|
||||||
|
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
|
||||||
|
guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil }
|
||||||
|
|
||||||
|
for id in deviceIDs {
|
||||||
|
if let devName = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceNameCFString) {
|
||||||
|
if devName == name {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDeviceByUID(_ uid: String) -> AudioObjectID? {
|
||||||
|
let systemID = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(systemID, &address, 0, nil, &size) == noErr else { return nil }
|
||||||
|
|
||||||
|
let count = Int(size) / MemoryLayout<AudioObjectID>.size
|
||||||
|
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
|
||||||
|
guard AudioObjectGetPropertyData(systemID, &address, 0, nil, &size, &deviceIDs) == noErr else { return nil }
|
||||||
|
|
||||||
|
for id in deviceIDs {
|
||||||
|
if let devUID = getStringProperty(objectID: id, selector: kAudioDevicePropertyDeviceUID) {
|
||||||
|
if devUID == uid {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAggregateDevice() {
|
||||||
|
print("Searching for devices...")
|
||||||
|
|
||||||
|
guard let blackHoleID = findDeviceByName("BlackHole 2ch") else {
|
||||||
|
print("Error: BlackHole 2ch not found. Please install it first.")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
print("Found BlackHole 2ch (ID: \(blackHoleID))")
|
||||||
|
|
||||||
|
// Default Input
|
||||||
|
var defaultInputID: AudioObjectID = 0
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
if AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &defaultInputID) != noErr {
|
||||||
|
print("Error: Could not find default input.")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
print("Found Default Input (ID: \(defaultInputID))")
|
||||||
|
|
||||||
|
// Check for existing "Hearbit Audio" by UID
|
||||||
|
let targetUID = "hearbit_audio_aggregate_v1"
|
||||||
|
if let existingID = findDeviceByUID(targetUID) {
|
||||||
|
print("Found existing Hearbit Audio device (ID: \(existingID)). Destroying to recreate...")
|
||||||
|
if AudioHardwareDestroyAggregateDevice(existingID) != noErr {
|
||||||
|
print("Warning: Failed to destroy existing device.")
|
||||||
|
} else {
|
||||||
|
print("Existing device destroyed.")
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SubDevice List
|
||||||
|
guard let bhUID = getStringProperty(objectID: blackHoleID, selector: kAudioDevicePropertyDeviceUID) else {
|
||||||
|
print("Error: Could not get BlackHole UID.")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
guard let micUID = getStringProperty(objectID: defaultInputID, selector: kAudioDevicePropertyDeviceUID) else {
|
||||||
|
print("Error: Could not get Default Input UID.")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: if Mic IS BlackHole (user set BlackHole as default), don't duplicate
|
||||||
|
var subDevicesUIDs = [bhUID]
|
||||||
|
if micUID != bhUID {
|
||||||
|
subDevicesUIDs.append(micUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
let subDevicesArray = subDevicesUIDs.map {
|
||||||
|
[kAudioSubDeviceUIDKey: $0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let desc: [String: Any] = [
|
||||||
|
kAudioAggregateDeviceNameKey: "Hearbit Audio",
|
||||||
|
kAudioAggregateDeviceUIDKey: targetUID,
|
||||||
|
kAudioAggregateDeviceIsPrivateKey: Int(0),
|
||||||
|
kAudioAggregateDeviceIsStackedKey: Int(0),
|
||||||
|
kAudioAggregateDeviceSubDeviceListKey: subDevicesArray
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Creating Aggregate Device with UIDs: \(subDevicesUIDs)")
|
||||||
|
|
||||||
|
var outID: AudioObjectID = 0
|
||||||
|
let err = AudioHardwareCreateAggregateDevice(desc as CFDictionary, &outID)
|
||||||
|
|
||||||
|
if err == noErr {
|
||||||
|
print("Success! Created 'Hearbit Audio' with ID: \(outID)")
|
||||||
|
exit(0)
|
||||||
|
} else {
|
||||||
|
print("Failed to create device. Error code: \(err) (\(err.fourCC))")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAggregateDevice()
|
||||||
196
src-tauri/src/audio_processor.rs
Normal file
196
src-tauri/src/audio_processor.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use cpal::Sample;
|
||||||
|
use hound::WavWriter;
|
||||||
|
use rubato::{Resampler, FastFixedIn, PolynomialDegree};
|
||||||
|
use voice_activity_detector::VoiceActivityDetector;
|
||||||
|
|
||||||
|
pub struct AudioProcessor {
|
||||||
|
// VAD
|
||||||
|
vad: VoiceActivityDetector,
|
||||||
|
vad_chunk_size: usize,
|
||||||
|
vad_buffer: Vec<f32>,
|
||||||
|
|
||||||
|
// Resampler
|
||||||
|
resampler: FastFixedIn<f32>,
|
||||||
|
resample_input_buffer: Vec<f32>,
|
||||||
|
resample_output_buffer: Vec<f32>,
|
||||||
|
|
||||||
|
// State
|
||||||
|
is_speech_active: bool,
|
||||||
|
last_speech_time: u64, // In samples or frames
|
||||||
|
hangover_samples: u64,
|
||||||
|
|
||||||
|
// Ring Buffer (for pre-roll)
|
||||||
|
ring_buffer: Vec<f32>,
|
||||||
|
ring_pos: usize,
|
||||||
|
ring_size: usize,
|
||||||
|
|
||||||
|
// Output
|
||||||
|
writer: Arc<Mutex<WavWriter<std::io::BufWriter<std::fs::File>>>>,
|
||||||
|
sample_rate: u32,
|
||||||
|
total_processed_samples: u64,
|
||||||
|
// Event Emission
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
last_event_time: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioProcessor {
|
||||||
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
writer: Arc<Mutex<WavWriter<std::io::BufWriter<std::fs::File>>>>,
|
||||||
|
app_handle: AppHandle
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let vad_sample_rate = 16000;
|
||||||
|
let vad_chunk_size = 512; // Silero usually likes ~30ms which is 512 at 16k? No 16000 * 0.032 = 512.
|
||||||
|
|
||||||
|
// Initialize VAD
|
||||||
|
let vad = VoiceActivityDetector::builder()
|
||||||
|
.sample_rate(vad_sample_rate as u32)
|
||||||
|
.chunk_size(vad_chunk_size)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to init VAD: {:?}", e))?;
|
||||||
|
|
||||||
|
// Initialize Resampler (Input Rate -> 16000) using FastFixedIn for speed/simplicity
|
||||||
|
// new(f_ratio, max_resample_ratio_relative, polyn_deg, chunk_size, channels)
|
||||||
|
let resampler = FastFixedIn::<f32>::new(
|
||||||
|
16000.0 / sample_rate as f64,
|
||||||
|
1.0,
|
||||||
|
PolynomialDegree::Septic,
|
||||||
|
1024,
|
||||||
|
1
|
||||||
|
).map_err(|e| format!("Failed to init Resampler: {:?}", e))?;
|
||||||
|
|
||||||
|
// Pre-roll buffer (e.g. 0.5 seconds of high quality audio)
|
||||||
|
let ring_curr_seconds = 1.0;
|
||||||
|
let ring_size = (sample_rate as f32 * ring_curr_seconds) as usize;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
vad,
|
||||||
|
vad_chunk_size,
|
||||||
|
vad_buffer: Vec::new(),
|
||||||
|
resampler,
|
||||||
|
resample_input_buffer: Vec::new(),
|
||||||
|
resample_output_buffer: Vec::new(),
|
||||||
|
is_speech_active: false,
|
||||||
|
last_speech_time: 0,
|
||||||
|
hangover_samples: (sample_rate as f32 * 1.5) as u64, // 1.5s hangover
|
||||||
|
ring_buffer: vec![0.0; ring_size],
|
||||||
|
ring_pos: 0,
|
||||||
|
ring_size,
|
||||||
|
writer,
|
||||||
|
sample_rate,
|
||||||
|
total_processed_samples: 0,
|
||||||
|
app_handle: Some(app_handle),
|
||||||
|
last_event_time: std::time::Instant::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, data: &[f32]) {
|
||||||
|
// 1. Add to Ring Buffer (always, for pre-roll)
|
||||||
|
for &sample in data {
|
||||||
|
self.ring_buffer[self.ring_pos] = sample;
|
||||||
|
self.ring_pos = (self.ring_pos + 1) % self.ring_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resample for VAD
|
||||||
|
// We append new data to input buffer for resampler
|
||||||
|
self.resample_input_buffer.extend_from_slice(data);
|
||||||
|
|
||||||
|
// Process in chunks compatible with resampler
|
||||||
|
// Actually rubato process_into_buffer needs waves of input.
|
||||||
|
// Simplified: SincFixedIn wants a fixed number of input frames?
|
||||||
|
// Docs: "retrieve result... input buffer must contain needed number of frames"
|
||||||
|
// SincFixedIn: "input buffer used for resampling... must receive a fixed number of frames"
|
||||||
|
// Wait, SincFixedIn is fixed INPUT size. SincFixedOut is fixed OUTPUT size.
|
||||||
|
// We want to feed whatever we get.
|
||||||
|
// For simplicity, let's use a simpler resampling strategy or accept rubato's constraints.
|
||||||
|
// Rubato SincFixedIn: we must provide `input_frames_next` frames.
|
||||||
|
|
||||||
|
// Let's defer strict resampling and just use decimation if sample rate is multiple?
|
||||||
|
// No, user devices vary.
|
||||||
|
|
||||||
|
// Handling Resampling properly:
|
||||||
|
let needed = self.resampler.input_frames_next();
|
||||||
|
while self.resample_input_buffer.len() >= needed {
|
||||||
|
let chunk: Vec<f32> = self.resample_input_buffer.drain(0..needed).collect();
|
||||||
|
// Resample (mono)
|
||||||
|
let waves_in = vec![chunk];
|
||||||
|
// Allocate output (approx)
|
||||||
|
let mut waves_out = vec![vec![0.0; (needed as f64 * (16000.0 / self.sample_rate as f64)).ceil() as usize + 10]; 1]; // +10 padding
|
||||||
|
|
||||||
|
if let Ok((_in_len, out_len)) = self.resampler.process_into_buffer(&waves_in, &mut waves_out, None) {
|
||||||
|
if out_len > 0 {
|
||||||
|
self.vad_buffer.extend_from_slice(&waves_out[0][0..out_len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update output buffer usage... logic is tricky with drain.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process VAD
|
||||||
|
while self.vad_buffer.len() >= self.vad_chunk_size {
|
||||||
|
let vad_chunk: Vec<f32> = self.vad_buffer.drain(0..self.vad_chunk_size).collect();
|
||||||
|
// Run Detection
|
||||||
|
// Run Detection
|
||||||
|
let probability = self.vad.predict(vad_chunk.clone());
|
||||||
|
|
||||||
|
// Calculate RMS for this chunk to use as fallback/hybrid detection
|
||||||
|
let sq_sum: f32 = vad_chunk.iter().map(|x| x * x).sum();
|
||||||
|
let rms = (sq_sum / vad_chunk.len() as f32).sqrt();
|
||||||
|
|
||||||
|
// Hybrid VAD: Probability > 0.4 OR RMS > 0.005 (approx -46dB)
|
||||||
|
let is_speech = probability > 0.4 || rms > 0.005;
|
||||||
|
|
||||||
|
if is_speech {
|
||||||
|
self.is_speech_active = true;
|
||||||
|
self.last_speech_time = self.total_processed_samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit VAD event periodically (every 500ms)
|
||||||
|
if self.last_event_time.elapsed().as_millis() > 500 {
|
||||||
|
// Calculate simple RMS of the current chunk for debugging
|
||||||
|
let sq_sum: f32 = vad_chunk.iter().map(|x| x * x).sum();
|
||||||
|
let rms = (sq_sum / vad_chunk.len() as f32).sqrt();
|
||||||
|
|
||||||
|
// Print debug info to stdout (viewable in terminal)
|
||||||
|
println!("VAD Debug: Prob={:.4}, RMS={:.6}, Speech={}", probability, rms, is_speech);
|
||||||
|
|
||||||
|
if let Some(app) = &self.app_handle {
|
||||||
|
// Just sending probability is enough for now
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct VadEvent {
|
||||||
|
probability: f32,
|
||||||
|
is_speech: bool,
|
||||||
|
}
|
||||||
|
let _ = app.emit("vad-event", VadEvent { probability, is_speech });
|
||||||
|
}
|
||||||
|
self.last_event_time = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update Hangover and Check Write condition
|
||||||
|
let time_since_speech = self.total_processed_samples.saturating_sub(self.last_speech_time);
|
||||||
|
|
||||||
|
if self.is_speech_active || time_since_speech < self.hangover_samples {
|
||||||
|
// We are recording!
|
||||||
|
// Check if we just started (transition)
|
||||||
|
// Ideally we dump the ring buffer here if we just switched state.
|
||||||
|
// Implementing perfect ring buffer dump is complex (need to track state changes better).
|
||||||
|
// MVP: Just Write Current Data if in state.
|
||||||
|
|
||||||
|
// Improvement: If we are in hangover, we just write.
|
||||||
|
// If we just detected speech (was not speech?), dump ring buffer?
|
||||||
|
// We'd need to know if we 'wrote' the ring buffer already.
|
||||||
|
|
||||||
|
// Simple Logic: just write all incoming data if (Now - LastSpeech < Hangover)
|
||||||
|
|
||||||
|
let mut guard = self.writer.lock().unwrap();
|
||||||
|
for &sample in data {
|
||||||
|
let amplitude = i16::MAX as f32;
|
||||||
|
guard.write_sample((sample * amplitude) as i16).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.total_processed_samples += data.len() as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src-tauri/src/auth.rs
Normal file
112
src-tauri/src/auth.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use tauri_plugin_oauth::start;
|
||||||
|
use url::Url;
|
||||||
|
use oauth2::{
|
||||||
|
basic::BasicClient, AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
|
||||||
|
TokenResponse, TokenUrl,
|
||||||
|
};
|
||||||
|
use oauth2::reqwest::async_http_client;
|
||||||
|
|
||||||
|
// const CLIENT_ID: &str = "YOUR_CLIENT_ID_HERE";
|
||||||
|
const AUTH_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
||||||
|
const TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_auth_flow<R: Runtime>(app: AppHandle<R>, client_id: String) -> Result<String, String> {
|
||||||
|
// 1. Start Localhost Server
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
// tauri-plugin-oauth start() returns a port and stops server when callback received
|
||||||
|
let port = start(move |url| {
|
||||||
|
// Ignore favicon requests to avoid triggering early
|
||||||
|
if url.contains("favicon.ico") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = tx.send(url);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to start oauth server: {}", e))?;
|
||||||
|
|
||||||
|
let redirect_uri = format!("http://localhost:{}/auth/callback", port);
|
||||||
|
|
||||||
|
// 2. Setup OAuth Client
|
||||||
|
let client = BasicClient::new(
|
||||||
|
ClientId::new(client_id),
|
||||||
|
None, // No client secret for PKCE public client
|
||||||
|
AuthUrl::new(AUTH_URL.to_string()).map_err(|e| e.to_string())?,
|
||||||
|
Some(TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| e.to_string())?),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(RedirectUrl::new(redirect_uri.clone()).map_err(|e| e.to_string())?);
|
||||||
|
|
||||||
|
// 3. Generate PKCE Challenge
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
|
// 4. Generate Auth URL
|
||||||
|
let (auth_url, _csrf_token) = client
|
||||||
|
.authorize_url(CsrfToken::new_random)
|
||||||
|
.add_scope(Scope::new("User.Read".to_string()))
|
||||||
|
.add_scope(Scope::new("Calendars.Read".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
// 5. Open Browser
|
||||||
|
app.opener().open_url(auth_url.as_str(), None::<&str>)
|
||||||
|
.map_err(|e| format!("Failed to open browser: {}", e))?;
|
||||||
|
|
||||||
|
// 6. Wait for Callback
|
||||||
|
let received_url_str = rx.recv().map_err(|e| format!("Failed to receive auth code: {}", e))?;
|
||||||
|
|
||||||
|
// 7. Parse Code from URL
|
||||||
|
// Actually we need to parse the query params from received_url_str
|
||||||
|
let parsed_url = Url::parse(&received_url_str).map_err(|e| e.to_string())?;
|
||||||
|
let pairs: std::collections::HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
|
||||||
|
|
||||||
|
if let Some(err) = pairs.get("error") {
|
||||||
|
let desc = pairs.get("error_description").map(|s| s.as_str()).unwrap_or("No description");
|
||||||
|
return Err(format!("OAuth Error: {} ({})", err, desc));
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = pairs.get("code").ok_or_else(|| format!("No code in redirect callback. Received URL: {}", received_url_str))?;
|
||||||
|
|
||||||
|
// 8. Exchange Code for Token
|
||||||
|
let token_result = client
|
||||||
|
.exchange_code(oauth2::AuthorizationCode::new(code.clone()))
|
||||||
|
.set_pkce_verifier(pkce_verifier)
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to exchange token: {}", e))?;
|
||||||
|
|
||||||
|
let access_token = token_result.access_token().secret();
|
||||||
|
|
||||||
|
// Save token? Or just return it.
|
||||||
|
// Ideally we save it in key storage, but for MVP return it.
|
||||||
|
|
||||||
|
Ok(access_token.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_calendar_events(token: String) -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let res = client
|
||||||
|
.get("https://graph.microsoft.com/v1.0/me/calendarView")
|
||||||
|
.bearer_auth(token)
|
||||||
|
.query(&[
|
||||||
|
("startDateTime", chrono::Utc::now().to_rfc3339()),
|
||||||
|
("endDateTime", (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339()),
|
||||||
|
("$select", "id,subject,start,end,location,onlineMeeting,bodyPreview,body,attendees".to_string())
|
||||||
|
])
|
||||||
|
.header("Prefer", "outlook.timezone=\"UTC\"")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Extract 'value' array
|
||||||
|
if let Some(events) = res.get("value").and_then(|v| v.as_array()) {
|
||||||
|
Ok(events.clone())
|
||||||
|
} else {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src-tauri/src/email.rs
Normal file
96
src-tauri/src/email.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use tauri::AppHandle;
|
||||||
|
use lettre::{Message, SmtpTransport, Transport, AsyncTransport};
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||||
|
use lettre::Tokio1Executor;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct SmtpConfig {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
sender_email: String,
|
||||||
|
sender_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct EmailMessage {
|
||||||
|
to: Vec<String>,
|
||||||
|
subject: String,
|
||||||
|
body_html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_smtp_email(app: AppHandle, config: SmtpConfig, message: EmailMessage) -> Result<String, String> {
|
||||||
|
println!("SMTP: Preparing to send email to {:?}", message.to);
|
||||||
|
|
||||||
|
// 1. Build Message
|
||||||
|
let sender_str = if let Some(name) = &config.sender_name {
|
||||||
|
format!("{} <{}>", name, config.sender_email)
|
||||||
|
} else {
|
||||||
|
config.sender_email.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(sender_str.parse().map_err(|e: lettre::address::AddressError| e.to_string())?)
|
||||||
|
.subject(message.subject);
|
||||||
|
|
||||||
|
for recipient in message.to {
|
||||||
|
builder = builder.to(recipient.parse().map_err(|e: lettre::address::AddressError| e.to_string())?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = builder
|
||||||
|
.header(lettre::message::header::ContentType::TEXT_HTML)
|
||||||
|
.body(message.body_html)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. Build Transport
|
||||||
|
println!("SMTP: Connecting to {}:{}...", config.host, config.port);
|
||||||
|
let creds = Credentials::new(config.username, config.password);
|
||||||
|
|
||||||
|
// TLS Configuration
|
||||||
|
// Proton Mail and others on 465 require Implicit TLS (Wrapper).
|
||||||
|
// Others on 587 use STARTTLS (Opportunistic).
|
||||||
|
|
||||||
|
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||||
|
|
||||||
|
let tls_params = TlsParameters::builder(config.host.clone())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("TLS Params error: {}", e))?;
|
||||||
|
|
||||||
|
let builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.port(config.port)
|
||||||
|
.credentials(creds);
|
||||||
|
|
||||||
|
let mailer: AsyncSmtpTransport<Tokio1Executor> = if config.port == 465 {
|
||||||
|
println!("SMTP: Using Implicit TLS (Wrapper) for port 465");
|
||||||
|
builder.tls(Tls::Wrapper(tls_params)).build()
|
||||||
|
} else {
|
||||||
|
println!("SMTP: Using Opportunistic TLS (STARTTLS) for port {}", config.port);
|
||||||
|
builder.tls(Tls::Opportunistic(tls_params)).build()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Send with Timeout
|
||||||
|
println!("SMTP: Sending...");
|
||||||
|
let send_task = mailer.send(email);
|
||||||
|
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(15), send_task).await {
|
||||||
|
Ok(result) => match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("SMTP: Success!");
|
||||||
|
Ok("Email sent successfully".to_string())
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
println!("SMTP: Failed: {}", e);
|
||||||
|
// Hint at common issues
|
||||||
|
Err(format!("Failed to send: {}. (Check Host/Port/Password)", e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
println!("SMTP: Timeout");
|
||||||
|
Err("Connection timed out after 15s. Try changing Port (465 vs 587) or check VPN/Firewall.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
mod audio_processor;
|
||||||
|
use audio_processor::AudioProcessor;
|
||||||
|
mod auth;
|
||||||
|
mod email;
|
||||||
|
|
||||||
// State to hold the active recording stream
|
// State to hold the active recording stream
|
||||||
struct AppState {
|
struct AppState {
|
||||||
recording_stream: Mutex<Option<cpal::Stream>>,
|
recording_stream: Mutex<Option<cpal::Stream>>,
|
||||||
@@ -60,7 +65,7 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
|
|||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>) -> Result<(), String> {
|
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>, custom_filename: Option<String>) -> Result<(), String> {
|
||||||
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
|
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
|
|
||||||
@@ -73,6 +78,15 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
|||||||
.ok_or("No input device found")?;
|
.ok_or("No input device found")?;
|
||||||
|
|
||||||
let config = device.default_input_config().map_err(|e| e.to_string())?;
|
let config = device.default_input_config().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// VAD requires 16Hz or 8kHz, typically. Silero likes 16k.
|
||||||
|
// We might need to resample or just check if the device supports it.
|
||||||
|
// For MVP VAD, we will try to stick to standard rates.
|
||||||
|
// Actually, simple energy VAD is easier to start with if Silero is too heavy or requires ONNX runtime.
|
||||||
|
// Let's check the crate docs or usage first.
|
||||||
|
// Wait, the user wants to IGNORE music. Energy VAD will fail on music.
|
||||||
|
// voice_activity_detector crate usually uses Silero or similar.
|
||||||
|
|
||||||
let spec = hound::WavSpec {
|
let spec = hound::WavSpec {
|
||||||
channels: config.channels(),
|
channels: config.channels(),
|
||||||
sample_rate: config.sample_rate(),
|
sample_rate: config.sample_rate(),
|
||||||
@@ -81,16 +95,22 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine file path: User provided or Temp
|
// Determine file path: User provided or Temp
|
||||||
|
let filename = if let Some(name) = custom_filename {
|
||||||
|
// Sanitize filename
|
||||||
|
let safe_name: String = name.chars().map(|x| if x.is_alphanumeric() || x == ' ' || x == '-' || x == '_' { x } else { '_' }).collect();
|
||||||
|
format!("{}.wav", safe_name)
|
||||||
|
} else {
|
||||||
|
format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())
|
||||||
|
};
|
||||||
|
|
||||||
let file_path = if let Some(path) = save_path {
|
let file_path = if let Some(path) = save_path {
|
||||||
if path.trim().is_empty() {
|
if path.trim().is_empty() {
|
||||||
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
std::env::temp_dir().join(&filename)
|
||||||
} else {
|
} else {
|
||||||
// Check if directory exists, if not try to create it or error out?
|
std::path::PathBuf::from(path).join(&filename)
|
||||||
// For now, assume user gives a valid directory. We'll append filename.
|
|
||||||
std::path::PathBuf::from(path).join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
std::env::temp_dir().join(&filename)
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_path_str = file_path.to_string_lossy().to_string();
|
let file_path_str = file_path.to_string_lossy().to_string();
|
||||||
@@ -100,6 +120,19 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
|||||||
let writer = Arc::new(Mutex::new(writer));
|
let writer = Arc::new(Mutex::new(writer));
|
||||||
let writer_clone = writer.clone();
|
let writer_clone = writer.clone();
|
||||||
|
|
||||||
|
// Initialize AudioProcessor (VAD)
|
||||||
|
// We pass the writer to it.
|
||||||
|
let processor = AudioProcessor::new(config.sample_rate(), writer.clone(), app.clone())
|
||||||
|
.map_err(|e| format!("Failed to create AudioProcessor: {}", e))?;
|
||||||
|
|
||||||
|
// Wrap processor in Arc<Mutex> so we can share/move it into callback
|
||||||
|
// Actually, cpal callback takes ownership of its closure state usually if 'move'.
|
||||||
|
// Since stream is on another thread, we need Send. AudioProcessor should be Send.
|
||||||
|
// However, the callback is called repeatedly. We need to keep state.
|
||||||
|
// The workaround is to wrap it in a Mutex.
|
||||||
|
let processor = Arc::new(Mutex::new(processor));
|
||||||
|
let processor_clone = processor.clone();
|
||||||
|
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let err_fn = move |err| {
|
let err_fn = move |err| {
|
||||||
eprintln!("an error occurred on stream: {}", err);
|
eprintln!("an error occurred on stream: {}", err);
|
||||||
@@ -110,21 +143,21 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
|||||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||||
&config.into(),
|
&config.into(),
|
||||||
move |data: &[f32], _: &_| {
|
move |data: &[f32], _: &_| {
|
||||||
let mut guard = writer_clone.lock().unwrap();
|
if let Ok(mut p) = processor_clone.lock() {
|
||||||
for &sample in data {
|
p.process(data);
|
||||||
let amplitude = i16::MAX as f32;
|
|
||||||
guard.write_sample((sample * amplitude) as i16).ok();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_fn,
|
err_fn,
|
||||||
None
|
None
|
||||||
),
|
),
|
||||||
|
// For I16 and U16 we need to convert to F32 for our processor
|
||||||
cpal::SampleFormat::I16 => device.build_input_stream(
|
cpal::SampleFormat::I16 => device.build_input_stream(
|
||||||
&config.into(),
|
&config.into(),
|
||||||
move |data: &[i16], _: &_| {
|
move |data: &[i16], _: &_| {
|
||||||
let mut guard = writer_clone.lock().unwrap();
|
// Convert i16 to f32
|
||||||
for &sample in data {
|
let f32_data: Vec<f32> = data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
|
||||||
guard.write_sample(sample).ok();
|
if let Ok(mut p) = processor_clone.lock() {
|
||||||
|
p.process(&f32_data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_fn,
|
err_fn,
|
||||||
@@ -133,9 +166,10 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
|||||||
cpal::SampleFormat::U16 => device.build_input_stream(
|
cpal::SampleFormat::U16 => device.build_input_stream(
|
||||||
&config.into(),
|
&config.into(),
|
||||||
move |data: &[u16], _: &_| {
|
move |data: &[u16], _: &_| {
|
||||||
let mut guard = writer_clone.lock().unwrap();
|
// Convert u16 to f32
|
||||||
for &sample in data {
|
let f32_data: Vec<f32> = data.iter().map(|&s| (s as i32 - 32768) as f32 / 32768.0).collect();
|
||||||
guard.write_sample((sample as i32 - 32768) as i16).ok();
|
if let Ok(mut p) = processor_clone.lock() {
|
||||||
|
p.process(&f32_data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_fn,
|
err_fn,
|
||||||
@@ -536,13 +570,86 @@ fn open_audio_midi_setup() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_hearbit_audio_device(app: AppHandle) -> Result<String, String> {
|
||||||
|
emit_log(&app, "INFO", "Attempting to create Hearbit Audio device...");
|
||||||
|
|
||||||
|
// Resolve resource path
|
||||||
|
let resource_path = app.path().resource_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("resources/create_hearbit_audio.swift");
|
||||||
|
|
||||||
|
if !resource_path.exists() {
|
||||||
|
// Fallback for dev environment where resources might not be bundled yet or different path
|
||||||
|
emit_log(&app, "WARN", &format!("Resource script not found at {:?}. Trying local src-tauri path.", resource_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, in dev mode, we might need to point to the source location if bundle isn't active
|
||||||
|
// But let's try running it.
|
||||||
|
|
||||||
|
let output = Command::new("swift")
|
||||||
|
.arg(resource_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
|
emit_log(&app, "DEBUG", &format!("Script Output: {}", stdout));
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
emit_log(&app, "WARN", &format!("Script Stderr: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
emit_log(&app, "SUCCESS", "Hearbit Audio device created successfully.");
|
||||||
|
Ok("Device created successfully".to_string())
|
||||||
|
} else {
|
||||||
|
emit_log(&app, "ERROR", "Failed to create device.");
|
||||||
|
Err(format!("Failed to create device: {} {}", stdout, stderr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn save_text_file(app: AppHandle, path: String, content: String) -> Result<(), String> {
|
||||||
|
emit_log(&app, "INFO", &format!("Saving text file to: {}", path));
|
||||||
|
match std::fs::write(&path, content) {
|
||||||
|
Ok(_) => {
|
||||||
|
emit_log(&app, "SUCCESS", "File saved successfully.");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
emit_log(&app, "ERROR", &format!("Failed to save file: {}", e));
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn read_log_file(app: AppHandle) -> Result<String, String> {
|
||||||
|
let log_path = app.path().app_log_dir().map_err(|e| e.to_string())?.join("hearbit-ai.log");
|
||||||
|
if log_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&log_path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(content)
|
||||||
|
} else {
|
||||||
|
Ok("No log file found yet.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_log::Builder::default()
|
||||||
|
.targets([
|
||||||
|
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||||
|
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("hearbit-ai.log".to_string()) }),
|
||||||
|
])
|
||||||
|
.build())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_oauth::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
recording_stream: Mutex::new(None),
|
recording_stream: Mutex::new(None),
|
||||||
recording_file_path: Mutex::new(None),
|
recording_file_path: Mutex::new(None),
|
||||||
@@ -557,7 +664,13 @@ pub fn run() {
|
|||||||
transcribe_audio,
|
transcribe_audio,
|
||||||
summarize_text,
|
summarize_text,
|
||||||
get_available_models,
|
get_available_models,
|
||||||
open_audio_midi_setup
|
open_audio_midi_setup,
|
||||||
|
create_hearbit_audio_device,
|
||||||
|
auth::start_auth_flow,
|
||||||
|
auth::get_calendar_events,
|
||||||
|
save_text_file,
|
||||||
|
read_log_file,
|
||||||
|
email::send_smtp_email
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Hearbit AI",
|
"productName": "Hearbit AI",
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"identifier": "com.hearbit-ai.desktop",
|
"identifier": "com.hearbit-ai.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
"resources/BlackHole2ch.v0.6.1.pkg"
|
"resources/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
293
src/App.tsx
293
src/App.tsx
@@ -1,26 +1,68 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { Settings as SettingsIcon } from "lucide-react";
|
import { Settings as SettingsIcon } from "lucide-react";
|
||||||
import Settings from "./components/Settings";
|
import Settings, { SmtpConfig, AzureConfig } from "./components/Settings";
|
||||||
import Recorder from "./components/Recorder";
|
import Recorder from "./components/Recorder";
|
||||||
import LogViewer, { LogEntry } from "./components/LogViewer";
|
|
||||||
import TranscriptionView from "./components/TranscriptionView";
|
import TranscriptionView from "./components/TranscriptionView";
|
||||||
import Tabs from "./components/Tabs";
|
import Tabs from "./components/Tabs";
|
||||||
|
import MeetingsView from "./components/MeetingsView";
|
||||||
|
import HistoryView from "./components/HistoryView";
|
||||||
|
import ToastContainer, { ToastMessage, ToastType } from "./components/ui/Toast";
|
||||||
|
|
||||||
export interface PromptTemplate {
|
export interface PromptTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription'>('recorder');
|
const [view, setView] = useState<'recorder' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||||
// Keep track of the *previous* tab to return to from settings
|
const [lastTab, setLastTab] = useState<'recorder' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||||
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription'>('recorder');
|
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-start recording state to handle "Join & Record" transition
|
||||||
|
const [autoStartRecording, setAutoStartRecording] = useState(false);
|
||||||
|
const [recordingSubject, setRecordingSubject] = useState<string>('');
|
||||||
|
|
||||||
|
// Toast State
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
const addToast = (message: string, type: ToastType = 'info', duration = 3000) => {
|
||||||
|
const id = Date.now().toString() + Math.random().toString();
|
||||||
|
setToasts(prev => [...prev, { id, message, type, duration }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
};
|
||||||
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
||||||
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
||||||
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
|
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
|
||||||
|
const [smtpConfig, setSmtpConfig] = useState<SmtpConfig>(() => {
|
||||||
|
const saved = localStorage.getItem('hearbit_smtp_config');
|
||||||
|
return saved ? JSON.parse(saved) : { host: '', port: '587', user: '', pass: '', sender: '', senderName: '' };
|
||||||
|
});
|
||||||
|
const [azureConfig, setAzureConfig] = useState<AzureConfig>(() => {
|
||||||
|
const saved = localStorage.getItem('hearbit_azure_config');
|
||||||
|
return saved ? JSON.parse(saved) : { clientId: '', tenantId: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>(() => {
|
||||||
|
return localStorage.getItem('hearbit_selected_model') || 'mixtral';
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleModelChange = (model: string) => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
localStorage.setItem('hearbit_selected_model', model);
|
||||||
|
};
|
||||||
|
|
||||||
// Default prompts if none exist
|
// Default prompts if none exist
|
||||||
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
|
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
|
||||||
@@ -61,7 +103,8 @@ Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklärt wurd
|
|||||||
| [Aufgabe 2] | [Name] | [Datum] |
|
| [Aufgabe 2] | [Name] | [Datum] |
|
||||||
|
|
||||||
## 5. Nächste Schritte / Nächstes Meeting
|
## 5. Nächste Schritte / Nächstes Meeting
|
||||||
Kurze Info zum weiteren Vorgehen.`
|
Kurze Info zum weiteren Vorgehen.`,
|
||||||
|
keywords: ['protokoll', 'meeting', 'team', 'daily', 'weekly']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -96,7 +139,8 @@ Thema B: [Kurze Zusammenfassung]
|
|||||||
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
|
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| [Name] | [Aufgabe] | [Datum] |
|
| [Name] | [Aufgabe] | [Datum] |
|
||||||
| [Name] | [Aufgabe] | [Datum] |`
|
| [Name] | [Aufgabe] | [Datum] |`,
|
||||||
|
keywords: ['personal', 'privat', 'vertraulich', 'entwicklungsgespräch', 'feedback', 'unter vier augen']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -138,7 +182,37 @@ Teilnehmer: [Namen Kunden] & [Namen Intern]
|
|||||||
[ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum])
|
[ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum])
|
||||||
|
|
||||||
## 5. Nächster Termin / Timeline
|
## 5. Nächster Termin / Timeline
|
||||||
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||||
|
keywords: ['beratung', 'kunde', 'client', 'angebot', 'projekt', 'extern']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default Email Templates
|
||||||
|
const defaultEmailTemplates: EmailTemplate[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Meeting Summary (Standard)',
|
||||||
|
subject: 'Meeting Summary: {{subject}}',
|
||||||
|
body: `Hi everyone,
|
||||||
|
|
||||||
|
Here is the summary of our meeting "{{subject}}" from {{date}}.
|
||||||
|
|
||||||
|
{{summary}}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Hearbit Assistant`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Action Items Only',
|
||||||
|
subject: 'Action Items: {{subject}}',
|
||||||
|
body: `Hi Team,
|
||||||
|
|
||||||
|
Please find below the action items from our call on {{date}}:
|
||||||
|
|
||||||
|
{{summary}}
|
||||||
|
|
||||||
|
Thanks!`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -147,15 +221,36 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
return saved ? JSON.parse(saved) : defaultPrompts;
|
return saved ? JSON.parse(saved) : defaultPrompts;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[], newSavePath: string) => {
|
const [emailTemplates, setEmailTemplates] = useState<EmailTemplate[]>(() => {
|
||||||
|
const saved = localStorage.getItem('hearbit_email_templates');
|
||||||
|
return saved ? JSON.parse(saved) : defaultEmailTemplates;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveSettings = (
|
||||||
|
newApiKey: string,
|
||||||
|
newProductId: string,
|
||||||
|
newPrompts: PromptTemplate[],
|
||||||
|
newSavePath: string,
|
||||||
|
newSmtp: SmtpConfig,
|
||||||
|
newAzure: AzureConfig,
|
||||||
|
newEmailTemplates: EmailTemplate[]
|
||||||
|
) => {
|
||||||
setApiKey(newApiKey);
|
setApiKey(newApiKey);
|
||||||
setProductId(newProductId);
|
setProductId(newProductId);
|
||||||
setPrompts(newPrompts);
|
setPrompts(newPrompts);
|
||||||
setSavePath(newSavePath);
|
setSavePath(newSavePath);
|
||||||
|
setSmtpConfig(newSmtp);
|
||||||
|
setAzureConfig(newAzure);
|
||||||
|
setEmailTemplates(newEmailTemplates);
|
||||||
|
|
||||||
localStorage.setItem('infomaniak_api_key', newApiKey);
|
localStorage.setItem('infomaniak_api_key', newApiKey);
|
||||||
localStorage.setItem('infomaniak_product_id', newProductId);
|
localStorage.setItem('infomaniak_product_id', newProductId);
|
||||||
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
|
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
|
||||||
localStorage.setItem('infomaniak_save_path', newSavePath);
|
localStorage.setItem('infomaniak_save_path', newSavePath);
|
||||||
|
localStorage.setItem('hearbit_smtp_config', JSON.stringify(newSmtp));
|
||||||
|
localStorage.setItem('hearbit_azure_config', JSON.stringify(newAzure));
|
||||||
|
localStorage.setItem('hearbit_email_templates', JSON.stringify(newEmailTemplates));
|
||||||
|
|
||||||
setView(lastTab);
|
setView(lastTab);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,6 +263,8 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
date: string;
|
date: string;
|
||||||
transcription: string;
|
transcription: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
subject?: string;
|
||||||
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [history, setHistory] = useState<HistoryItem[]>(() => {
|
const [history, setHistory] = useState<HistoryItem[]>(() => {
|
||||||
@@ -179,16 +276,39 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
const transToSave = t !== undefined ? t : transcription;
|
const transToSave = t !== undefined ? t : transcription;
|
||||||
const sumToSave = s !== undefined ? s : summary;
|
const sumToSave = s !== undefined ? s : summary;
|
||||||
|
|
||||||
|
// Sanitize subject for filename
|
||||||
|
const safeSubject = recordingSubject
|
||||||
|
? recordingSubject.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||||
|
: `Meeting_${Date.now()}`;
|
||||||
|
const filename = `${safeSubject}.md`;
|
||||||
|
|
||||||
if (!transToSave && !sumToSave) return;
|
if (!transToSave && !sumToSave) return;
|
||||||
|
|
||||||
const newItem: HistoryItem = {
|
const newItem: HistoryItem = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
date: new Date().toLocaleString(),
|
date: new Date().toLocaleString(),
|
||||||
transcription: transToSave,
|
transcription: transToSave,
|
||||||
summary: sumToSave
|
summary: sumToSave,
|
||||||
|
subject: recordingSubject || "Untitled Recording",
|
||||||
|
filename: filename
|
||||||
};
|
};
|
||||||
const newHistory = [newItem, ...history];
|
const newHistory = [newItem, ...history];
|
||||||
setHistory(newHistory);
|
setHistory(newHistory);
|
||||||
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
|
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
|
||||||
|
|
||||||
|
// Persist to Disk (Markdown)
|
||||||
|
const content = `# ${newItem.subject}\nDate: ${newItem.date}\n\n## Summary\n${sumToSave}\n\n## Transcription\n${transToSave}`;
|
||||||
|
// If savePath is set, we use it. If not, backend defaults to temp. Here we want to save text.
|
||||||
|
// Let's assume savePath is set or we default to Documents/Hearbit (if we could).
|
||||||
|
// For now, if savePath is set, use it.
|
||||||
|
if (savePath) {
|
||||||
|
// We need invoke to save text
|
||||||
|
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||||
|
invoke('save_text_file', { path: `${savePath}/${filename}`, content })
|
||||||
|
.then(() => addToast('Transcript saved to file', 'success'))
|
||||||
|
.catch(e => addToast(`Failed to save file: ${e}`, 'error'));
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteHistory = (id: string) => {
|
const handleDeleteHistory = (id: string) => {
|
||||||
@@ -200,21 +320,10 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
const handleLoadHistory = (item: HistoryItem) => {
|
const handleLoadHistory = (item: HistoryItem) => {
|
||||||
setTranscription(item.transcription);
|
setTranscription(item.transcription);
|
||||||
setSummary(item.summary);
|
setSummary(item.summary);
|
||||||
setView('recorder'); // Ensure we go back to recorder to see it
|
setView('transcription'); // Switch to Transcription view to see content
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logs State
|
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlisten = listen<LogEntry>('log-event', (event) => {
|
|
||||||
setLogs((prevLogs) => [...prevLogs, event.payload]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then(f => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
|
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
|
||||||
@@ -224,7 +333,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
<div className="absolute right-4 top-4">
|
<div className="absolute right-4 top-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLastTab(view === 'logs' ? 'logs' : 'recorder');
|
setLastTab(view === 'history' ? view : 'recorder');
|
||||||
setView('settings');
|
setView('settings');
|
||||||
}}
|
}}
|
||||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
|
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
|
||||||
@@ -234,53 +343,109 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
currentTab={view as 'recorder' | 'logs' | 'transcription'}
|
currentTab={view as 'recorder' | 'transcription' | 'meetings' | 'history'}
|
||||||
onTabChange={(t) => setView(t)}
|
onTabChange={(t) => setView(t)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
<div className="flex-1 flex h-full overflow-hidden relative">
|
||||||
{view === 'recorder' && (
|
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||||
<Recorder
|
{view === 'recorder' && (
|
||||||
apiKey={apiKey}
|
<Recorder
|
||||||
productId={productId}
|
apiKey={apiKey}
|
||||||
prompts={prompts}
|
productId={productId}
|
||||||
onOpenSettings={() => {
|
prompts={prompts}
|
||||||
setLastTab('recorder');
|
onOpenSettings={() => {
|
||||||
setView('settings');
|
setLastTab('recorder');
|
||||||
}}
|
setView('settings');
|
||||||
transcription={transcription}
|
}}
|
||||||
setTranscription={setTranscription}
|
transcription={transcription}
|
||||||
summary={summary}
|
setTranscription={setTranscription}
|
||||||
setSummary={setSummary}
|
summary={summary}
|
||||||
history={history}
|
setSummary={setSummary}
|
||||||
onSaveToHistory={handleSaveToHistory}
|
history={history}
|
||||||
onDeleteHistory={handleDeleteHistory}
|
onSaveToHistory={handleSaveToHistory}
|
||||||
onLoadHistory={handleLoadHistory}
|
onDeleteHistory={handleDeleteHistory}
|
||||||
savePath={savePath}
|
onLoadHistory={handleLoadHistory}
|
||||||
onRecordingComplete={() => setView('transcription')}
|
savePath={savePath}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'transcription' && (
|
onRecordingComplete={() => setView('transcription')}
|
||||||
<TranscriptionView transcription={transcription} summary={summary} />
|
autoStart={autoStartRecording}
|
||||||
)}
|
recordingSubject={recordingSubject}
|
||||||
|
onAutoStartHandled={() => setAutoStartRecording(false)}
|
||||||
|
addToast={addToast}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === 'logs' && (
|
{view === 'transcription' && (
|
||||||
<LogViewer logs={logs} />
|
<TranscriptionView
|
||||||
)}
|
transcription={transcription}
|
||||||
|
summary={summary}
|
||||||
|
smtpConfig={smtpConfig}
|
||||||
|
apiKey={apiKey}
|
||||||
|
productId={productId}
|
||||||
|
prompts={prompts}
|
||||||
|
emailTemplates={emailTemplates}
|
||||||
|
onUpdateSummary={(newSummary) => {
|
||||||
|
setSummary(newSummary); // Update view
|
||||||
|
// Also update history item if it exists
|
||||||
|
// We identify by transcription content match (simple heuristic) or we should track currentId
|
||||||
|
const histIdx = history.findIndex(h => h.transcription === transcription);
|
||||||
|
if (histIdx >= 0) {
|
||||||
|
const newHist = [...history];
|
||||||
|
newHist[histIdx] = { ...newHist[histIdx], summary: newSummary };
|
||||||
|
setHistory(newHist);
|
||||||
|
localStorage.setItem('infomaniak_history', JSON.stringify(newHist));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === 'settings' && (
|
{view === 'history' && (
|
||||||
<Settings
|
<HistoryView
|
||||||
onSave={handleSaveSettings}
|
history={history}
|
||||||
onClose={() => setView(lastTab)}
|
onLoad={handleLoadHistory}
|
||||||
apiKey={apiKey}
|
onDelete={handleDeleteHistory}
|
||||||
productId={productId}
|
/>
|
||||||
prompts={prompts}
|
)}
|
||||||
savePath={savePath}
|
|
||||||
/>
|
{view === 'meetings' && (
|
||||||
)}
|
|
||||||
|
<MeetingsView
|
||||||
|
azureClientId={azureConfig.clientId}
|
||||||
|
onStartRecording={(subject) => {
|
||||||
|
setView('recorder');
|
||||||
|
setRecordingSubject(subject || '');
|
||||||
|
setAutoStartRecording(true);
|
||||||
|
}}
|
||||||
|
apiKey={apiKey}
|
||||||
|
productId={productId}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{view === 'settings' && (
|
||||||
|
<Settings
|
||||||
|
onSave={handleSaveSettings}
|
||||||
|
onClose={() => setView(lastTab)}
|
||||||
|
apiKey={apiKey}
|
||||||
|
productId={productId}
|
||||||
|
prompts={prompts}
|
||||||
|
savePath={savePath}
|
||||||
|
smtpConfig={smtpConfig}
|
||||||
|
azureConfig={azureConfig}
|
||||||
|
emailTemplates={emailTemplates}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
280
src/components/EmailPreviewModal.tsx
Normal file
280
src/components/EmailPreviewModal.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Mail, X, Send } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
import { EmailTemplate } from '../App';
|
||||||
|
|
||||||
|
interface EmailPreviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialRecipients: string[];
|
||||||
|
initialSubject: string;
|
||||||
|
initialBody: string;
|
||||||
|
emailTemplates: EmailTemplate[]; // New prop
|
||||||
|
smtpConfig: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
sender: string;
|
||||||
|
senderName?: string;
|
||||||
|
} | null;
|
||||||
|
addToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic Markdown to HTML converter for email body
|
||||||
|
const formatMarkdownToHtml = (markdown: string): string => {
|
||||||
|
let html = markdown
|
||||||
|
// Headers
|
||||||
|
.replace(/^# (.*$)/gim, '<h1 style="color: #1a1a1a; font-size: 24px; margin-top: 20px; margin-bottom: 10px; border-bottom: 2px solid #eaeaea; padding-bottom: 10px;">$1</h1>')
|
||||||
|
.replace(/^## (.*$)/gim, '<h2 style="color: #2d2d2d; font-size: 20px; margin-top: 25px; margin-bottom: 10px;">$1</h2>')
|
||||||
|
.replace(/^### (.*$)/gim, '<h3 style="color: #404040; font-size: 18px; margin-top: 20px; margin-bottom: 8px;">$1</h3>')
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
.replace(/^\s*-\s+(.*$)/gim, '<li style="margin-bottom: 5px;">$1</li>')
|
||||||
|
// Wrap lists (simple heuristic)
|
||||||
|
.replace(/(<li.*<\/li>)/gim, '<ul>$1</ul>')
|
||||||
|
.replace(/<\/ul>\s*<ul>/gim, '') // Merge adjacent lists
|
||||||
|
|
||||||
|
// Tables (Basic support for the format used in prompts)
|
||||||
|
.replace(/\| (.*?) \| (.*?) \| (.*?) \|/gim, '<tr><td style="border: 1px solid #ddd; padding: 8px;">$1</td><td style="border: 1px solid #ddd; padding: 8px;">$2</td><td style="border: 1px solid #ddd; padding: 8px;">$3</td></tr>')
|
||||||
|
.replace(/\| :--- \| :--- \| :--- \|/gim, '') // Remove separator row
|
||||||
|
// Tables wrapping (heuristic)
|
||||||
|
.replace(/(<tr>.*<\/tr>)/gim, '<table style="width: 100%; border-collapse: collapse; margin-top: 10px; margin-bottom: 20px;">$1</table>')
|
||||||
|
.replace(/<\/table>\s*<table.*?>/gim, '') // Merge adjacent tables
|
||||||
|
|
||||||
|
// Paragraphs (double newlines)
|
||||||
|
.replace(/\n\n/gim, '<br><br>');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||||
|
isOpen, onClose, initialRecipients, initialSubject, initialBody, smtpConfig, addToast, emailTemplates
|
||||||
|
}) => {
|
||||||
|
const [recipients, setRecipients] = useState<string>(initialRecipients.join(', '));
|
||||||
|
const [subject, setSubject] = useState(initialSubject);
|
||||||
|
const [body, setBody] = useState(initialBody);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'preview' | 'source'>('preview');
|
||||||
|
|
||||||
|
const generateHtmlBody = (content: string, title: string) => {
|
||||||
|
// Simple heuristic: if it looks like HTML, treat as HTML. Otherwise, markdown.
|
||||||
|
const isHtml = /^\s*<(!DOCTYPE|html|div|p|table)/i.test(content);
|
||||||
|
const formattedBody = isHtml ? content : formatMarkdownToHtml(content);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f4f4f5; color: #333;">
|
||||||
|
<div style="max-width: 640px; margin: 40px auto; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background-color: #000000; padding: 30px 40px;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600; letter-spacing: -0.5px;">${title}</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px; line-height: 1.6;">
|
||||||
|
${formattedBody}
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background-color: #f9fafb; padding: 20px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #6b7280;">
|
||||||
|
Generated by <strong>Hearbit AI</strong> • Secure & Local Meeting Intelligence
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setRecipients(initialRecipients.join(', '));
|
||||||
|
setSubject(initialSubject);
|
||||||
|
// Default: Wrap initialBody in our HTML template
|
||||||
|
setBody(generateHtmlBody(initialBody, initialSubject));
|
||||||
|
setSelectedTemplateId('');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialRecipients, initialSubject, initialBody]);
|
||||||
|
|
||||||
|
const handleTemplateChange = (tmplId: string) => {
|
||||||
|
setSelectedTemplateId(tmplId);
|
||||||
|
if (!tmplId) return;
|
||||||
|
|
||||||
|
const tmpl = emailTemplates.find(t => t.id === tmplId);
|
||||||
|
if (tmpl) {
|
||||||
|
// Replace placeholders
|
||||||
|
const dateStr = new Date().toLocaleDateString();
|
||||||
|
let newSub = tmpl.subject.replace(/{{date}}/g, dateStr).replace(/{{subject}}/g, "Meeting");
|
||||||
|
// Note: We don't have the original 'recordingSubject' here easily without more prop drilling,
|
||||||
|
// so we default to "Meeting" or user can edit.
|
||||||
|
// Actually, initialSubject usually contains "Meeting Summary", so we could parse it, but for now date/summary is most important.
|
||||||
|
|
||||||
|
let newBody = tmpl.body
|
||||||
|
.replace(/{{date}}/g, dateStr)
|
||||||
|
.replace(/{{subject}}/g, "the meeting")
|
||||||
|
.replace(/{{summary}}/g, initialBody);
|
||||||
|
|
||||||
|
setSubject(newSub);
|
||||||
|
setBody(generateHtmlBody(newBody, newSub));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!smtpConfig || !smtpConfig.host) {
|
||||||
|
addToast("SMTP Settings not configured. Please go to Settings.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
// Split recipients by comma/semicolon and clean
|
||||||
|
const toList = recipients.split(/[,;]/).map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
|
||||||
|
await invoke('send_smtp_email', {
|
||||||
|
config: {
|
||||||
|
host: smtpConfig.host,
|
||||||
|
port: Number(smtpConfig.port),
|
||||||
|
username: smtpConfig.user,
|
||||||
|
password: smtpConfig.pass,
|
||||||
|
sender_email: smtpConfig.sender,
|
||||||
|
sender_name: smtpConfig.senderName
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
to: toList,
|
||||||
|
subject: subject,
|
||||||
|
body_html: body
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addToast("Email sent successfully!", "success");
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
addToast(`Failed to send email: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl flex flex-col h-[90vh]">
|
||||||
|
<div className="p-4 border-b border-border flex justify-between items-center shrink-0">
|
||||||
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
|
<Mail size={18} /> Send Summary via Email
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto flex-1 flex flex-col">
|
||||||
|
{/* Template Selector */}
|
||||||
|
{emailTemplates.length > 0 && (
|
||||||
|
<div className="bg-secondary/20 p-3 rounded-lg border border-border/50">
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
|
||||||
|
Load Template
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||||
|
className="w-full text-sm p-2 rounded border border-border bg-background outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">-- Select a Template --</option>
|
||||||
|
{emailTemplates.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1">To</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipients}
|
||||||
|
onChange={(e) => setRecipients(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||||
|
placeholder="email@example.com, other@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1">Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 border border-border rounded-lg overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-border bg-secondary/30">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('preview')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'preview' ? 'bg-background text-primary border-r border-border' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('source')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'source' ? 'bg-background text-primary border-l border-r border-border' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
HTML Source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 bg-background overflow-hidden relative">
|
||||||
|
{activeTab === 'preview' ? (
|
||||||
|
<div className="w-full h-full bg-white text-black">
|
||||||
|
<iframe
|
||||||
|
srcDoc={body}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
title="Email Preview"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
className="w-full h-full p-4 text-sm font-mono resize-none focus:outline-none bg-background text-foreground"
|
||||||
|
placeholder="<html>...</html>"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border flex justify-end gap-2 bg-secondary/10 rounded-b-xl">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-all flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending ? 'Sending...' : <><Send size={16} /> Send Email</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailPreviewModal;
|
||||||
216
src/components/EmailTemplateEditor.tsx
Normal file
216
src/components/EmailTemplateEditor.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Eye, Send, Code } from 'lucide-react';
|
||||||
|
import { EmailTemplate } from '../App';
|
||||||
|
import { SmtpConfig } from './Settings';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
interface EmailTemplateEditorProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
template: EmailTemplate | null;
|
||||||
|
onSave: (template: EmailTemplate) => void;
|
||||||
|
smtpConfig: SmtpConfig;
|
||||||
|
addToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||||
|
isOpen, onClose, template, onSave, smtpConfig, addToast
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit');
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [sendingTest, setSendingTest] = useState(false);
|
||||||
|
|
||||||
|
// Load template data when it changes or opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (template) {
|
||||||
|
setName(template.name);
|
||||||
|
setSubject(template.subject);
|
||||||
|
setBody(template.body);
|
||||||
|
} else {
|
||||||
|
// New template defaults
|
||||||
|
setName('New Template');
|
||||||
|
setSubject('Subject: {{subject}}');
|
||||||
|
setBody('{{summary}}');
|
||||||
|
}
|
||||||
|
}, [template, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!template) {
|
||||||
|
// Create new
|
||||||
|
onSave({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name,
|
||||||
|
subject,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update existing
|
||||||
|
onSave({
|
||||||
|
...template,
|
||||||
|
name,
|
||||||
|
subject,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendTest = async () => {
|
||||||
|
if (!testEmail) {
|
||||||
|
addToast('Please enter a test email address', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!smtpConfig.host) {
|
||||||
|
addToast('SMTP settings missing in main settings', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendingTest(true);
|
||||||
|
try {
|
||||||
|
// Basic substitution for preview
|
||||||
|
// Basic substitution for preview
|
||||||
|
// If markdown (simple check), convert? For now, we assume user is writing HTML or we leave it raw if they want.
|
||||||
|
// But the request is to "use HTML format".
|
||||||
|
// If it looks like HTML, use it. If not, maybe wrap in <p>?
|
||||||
|
// For the test email, we pass it as body_html.
|
||||||
|
|
||||||
|
await invoke('send_smtp_email', {
|
||||||
|
config: {
|
||||||
|
host: smtpConfig.host,
|
||||||
|
port: Number(smtpConfig.port),
|
||||||
|
username: smtpConfig.user,
|
||||||
|
password: smtpConfig.pass,
|
||||||
|
sender_email: smtpConfig.sender,
|
||||||
|
sender_name: smtpConfig.senderName
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
to: [testEmail],
|
||||||
|
subject: `[TEST] ${subject}`,
|
||||||
|
body_html: body // Send raw body (HTML)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addToast('Test email sent!', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
addToast(`Failed to send test: ${e}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSendingTest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl flex flex-col h-[85vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/20">
|
||||||
|
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||||
|
{template ? 'Edit Email Template' : 'Create Email Template'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 container mx-auto p-4 gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1">Template Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-background focus:ring-2 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1">Subject Pattern</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-background focus:ring-2 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex bg-secondary/30 border border-border rounded-t-lg mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('edit')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${activeTab === 'edit' ? 'bg-background border-b-0 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
<Code size={16} /> Edit HTML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('preview')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${activeTab === 'preview' ? 'bg-background border-b-0 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
<Eye size={16} /> Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 border border-t-0 border-border rounded-b-lg bg-background overflow-hidden relative">
|
||||||
|
{activeTab === 'edit' ? (
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
className="w-full h-full p-4 font-mono text-sm resize-none focus:outline-none bg-background text-foreground"
|
||||||
|
placeholder="<html><body>...</body></html> or Markdown"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-white text-black">
|
||||||
|
<iframe
|
||||||
|
srcDoc={body}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
title="Email Preview"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Email Section */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="test@example.com"
|
||||||
|
className="flex-1 p-2 rounded border border-border bg-secondary/50 text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendTest}
|
||||||
|
disabled={sendingTest || !testEmail}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-secondary hover:bg-secondary/80 text-foreground rounded border border-border transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{sendingTest ? 'Sending...' : <><Send size={14} /> Send Test</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-border flex justify-end gap-2 bg-secondary/10">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-6 py-2 text-sm font-medium bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Save Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailTemplateEditor;
|
||||||
67
src/components/HistoryView.tsx
Normal file
67
src/components/HistoryView.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { FileText, Trash2, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
transcription: string; // This might be raw text or path?
|
||||||
|
summary: string;
|
||||||
|
subject?: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryViewProps {
|
||||||
|
history: HistoryItem[];
|
||||||
|
onLoad: (item: HistoryItem) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryView({ history, onLoad, onDelete }: HistoryViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full h-full bg-background p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||||
|
<FileText className="w-8 h-8" />
|
||||||
|
Recording History
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<FileText size={48} className="mb-4 opacity-20" />
|
||||||
|
<p>No history found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||||
|
{history.map(item => (
|
||||||
|
<div key={item.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-pointer"
|
||||||
|
onClick={() => onLoad(item)}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-1">
|
||||||
|
{item.subject || "Untitled Recording"}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{item.date}
|
||||||
|
{item.filename && <span className="bg-secondary px-1.5 py-0.5 rounded text-[10px] font-mono">{item.filename}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground/70 line-clamp-2">
|
||||||
|
{item.summary ? item.summary.substring(0, 150) + "..." : "No summary available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
|
||||||
|
className="text-muted-foreground hover:text-destructive p-2 rounded-lg hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
src/components/MeetingsView.tsx
Normal file
351
src/components/MeetingsView.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Calendar, RefreshCw, LogIn, Video } from 'lucide-react';
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
start: { dateTime: string, timeZone: string };
|
||||||
|
end: { dateTime: string, timeZone: string };
|
||||||
|
onlineMeeting?: { joinUrl: string };
|
||||||
|
location?: { displayName: string };
|
||||||
|
bodyPreview?: string; // Text preview
|
||||||
|
body?: { content: string, contentType: string }; // Full HTML/Text
|
||||||
|
attendees?: { emailAddress: { name: string, address: string }, type: string, status: { response: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeetingsViewProps {
|
||||||
|
onStartRecording: (subject?: string) => void;
|
||||||
|
azureClientId: string;
|
||||||
|
apiKey: string;
|
||||||
|
productId: string;
|
||||||
|
selectedModel: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeetingsView({ onStartRecording, azureClientId, apiKey, productId, selectedModel, onModelChange }: MeetingsViewProps) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [token, setToken] = useState(localStorage.getItem('m365_token') || '');
|
||||||
|
// const [clientId, setClientId] = useState(azureClientId); // Use prop directly
|
||||||
|
|
||||||
|
// Sync prop to state if needed, or just use prop.
|
||||||
|
// Let's us prop directly in startAuthFlow
|
||||||
|
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
const newSet = new Set(expandedIds);
|
||||||
|
if (newSet.has(id)) {
|
||||||
|
newSet.delete(id);
|
||||||
|
} else {
|
||||||
|
newSet.add(id);
|
||||||
|
}
|
||||||
|
setExpandedIds(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apiKey && productId) {
|
||||||
|
loadModels();
|
||||||
|
}
|
||||||
|
}, [apiKey, productId]);
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
||||||
|
if (models && models.length > 0) {
|
||||||
|
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setAvailableModels(models);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load models:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
fetchEvents(token);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!azureClientId) {
|
||||||
|
setError("Please configure Client ID in Settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem('m365_client_id', azureClientId);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const accessToken = await invoke<string>('start_auth_flow', { clientId: azureClientId });
|
||||||
|
setToken(accessToken);
|
||||||
|
localStorage.setItem('m365_token', accessToken);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
fetchEvents(accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Auth failed", err);
|
||||||
|
setError(String(err)); // Use String() to safely convert error object
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchEvents = async (authToken: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await invoke<CalendarEvent[]>('get_calendar_events', { token: authToken });
|
||||||
|
// Sort by start time
|
||||||
|
const sorted = data.sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime());
|
||||||
|
setEvents(sorted);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fetch failed", err);
|
||||||
|
setError(`Fetch failed: ${err}`);
|
||||||
|
// If error is 401, logout
|
||||||
|
if (String(err).includes('401')) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken('');
|
||||||
|
localStorage.removeItem('m365_token');
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setEvents([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (joinUrl?: string, subject?: string) => {
|
||||||
|
if (!joinUrl) return;
|
||||||
|
try {
|
||||||
|
// 1. Open URL
|
||||||
|
await openUrl(joinUrl);
|
||||||
|
// 2. Start Recording (wait a sec for app focus switch?)
|
||||||
|
// Actually user might want to confirm recording? Protocol says "one-click".
|
||||||
|
onStartRecording(subject);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to join", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (isoString: string) => {
|
||||||
|
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (isoString: string) => {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) return "Today";
|
||||||
|
if (date.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||||
|
return date.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group events by date
|
||||||
|
const groupedEvents = events.reduce((groups, event) => {
|
||||||
|
const dateKey = formatDate(event.start.dateTime);
|
||||||
|
if (!groups[dateKey]) {
|
||||||
|
groups[dateKey] = [];
|
||||||
|
}
|
||||||
|
groups[dateKey].push(event);
|
||||||
|
return groups;
|
||||||
|
}, {} as Record<string, CalendarEvent[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full h-full bg-background p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||||
|
<Calendar className="w-8 h-8" />
|
||||||
|
Upcoming Meetings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Auth Section */}
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 gap-6 text-center max-w-md mx-auto">
|
||||||
|
<div className="bg-secondary/30 p-8 rounded-xl border border-border">
|
||||||
|
<Calendar className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Connect Microsoft 365</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Connect your account to see upcoming Teams & Zoom meetings and join them with one click.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="text-sm p-3 bg-secondary/50 rounded border border-border text-center">
|
||||||
|
{azureClientId ? (
|
||||||
|
<span className="text-green-600 font-medium">Client ID Configured</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-destructive font-medium">Client ID Missing in Settings</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading || !azureClientId}
|
||||||
|
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 w-full transition-all"
|
||||||
|
>
|
||||||
|
{loading ? <RefreshCw className="animate-spin" size={16} /> : <LogIn size={16} />}
|
||||||
|
Connect Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-3 bg-destructive/10 text-destructive text-xs rounded-md text-left break-all">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-4 px-2">
|
||||||
|
Note: Requires an Azure App Registration (Multitenant) with redirect URI: <br />
|
||||||
|
<code className="bg-secondary px-1 rounded">http://localhost:14200/auth/callback</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div className="flex justify-between items-center mb-4 px-1">
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">Next 7 Days</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Model Selector */}
|
||||||
|
<div className="flex items-center gap-2 bg-secondary/50 p-1 rounded-lg border border-border/50">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground pl-2">Using:</span>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
|
className="bg-transparent text-xs font-medium outline-none text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{availableModels.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => fetchEvents(token)} disabled={loading} className="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-secondary transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button onClick={logout} className="text-muted-foreground hover:text-destructive p-1 rounded hover:bg-destructive/10 transition-colors" title="Logout">
|
||||||
|
<LogIn size={16} className="rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 && !loading && (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
{/* No meetings empty state (only if no error) */}
|
||||||
|
<Calendar size={48} className="mb-4 opacity-20" />
|
||||||
|
<p>No upcoming meetings found for the next 7 days.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="m-4 p-3 bg-destructive/10 text-destructive text-sm rounded-md flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => fetchEvents(token)} className="underline hover:no-underline ml-2">Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 space-y-6 pb-20">
|
||||||
|
{Object.entries(groupedEvents).map(([dateLabel, dateEvents]) => (
|
||||||
|
<div key={dateLabel} className="space-y-3">
|
||||||
|
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur py-2 border-b border-border/50 text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{dateLabel}
|
||||||
|
</div>
|
||||||
|
{dateEvents.map(event => (
|
||||||
|
<div key={event.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-mono font-medium text-foreground">
|
||||||
|
{formatTime(event.start.dateTime)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
- {formatTime(event.end.dateTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold group-hover:text-primary transition-colors leading-tight">
|
||||||
|
{event.subject}
|
||||||
|
</h3>
|
||||||
|
{event.location?.displayName && (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
📍 {event.location.displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.onlineMeeting?.joinUrl ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleJoin(event.onlineMeeting?.joinUrl, event.subject)}
|
||||||
|
className="shrink-0 bg-green-600 hover:bg-green-700 text-white p-2 rounded-lg shadow-sm hover:shadow transition-all flex flex-col items-center justify-center gap-0.5 min-w-[70px]"
|
||||||
|
title={`Join & Summarize with ${selectedModel}`}
|
||||||
|
>
|
||||||
|
<Video size={16} />
|
||||||
|
<span className="text-[10px] font-bold">JOIN</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1 bg-secondary text-muted-foreground text-[10px] rounded italic">
|
||||||
|
No Link
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand/Collapse Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(event.id)}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-primary mt-2 flex items-center gap-1 transition-colors w-full justify-center py-0.5 bg-secondary/30 hover:bg-secondary/50 rounded"
|
||||||
|
>
|
||||||
|
{expandedIds.has(event.id) ? "Hide Details" : "Show Details"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{expandedIds.has(event.id) && (
|
||||||
|
<div className="mt-2 text-xs text-foreground/80 bg-background/50 p-3 rounded border border-border/50 animate-in fade-in slide-in-from-top-1">
|
||||||
|
{event.body?.content ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-xs dark:prose-invert max-w-none break-words"
|
||||||
|
dangerouslySetInnerHTML={{ __html: event.body.content }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap">{event.bodyPreview || "No details available."}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.attendees && event.attendees.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-border/50">
|
||||||
|
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
👥 Attendees ({event.attendees.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{event.attendees.map((att, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 bg-secondary/50 border border-border/50 px-2 py-1 rounded text-xs transition-colors">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${att.status.response === 'accepted' ? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.4)]' :
|
||||||
|
att.status.response === 'declined' ? 'bg-red-500' : 'bg-yellow-500'
|
||||||
|
}`} title={`Status: ${att.status.response}`} />
|
||||||
|
<span className="truncate max-w-[150px]" title={att.emailAddress.address}>
|
||||||
|
{att.emailAddress.name || att.emailAddress.address}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Mic, Square } from 'lucide-react';
|
import { Mic, Square, Users, Headphones } from 'lucide-react';
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import logo from '../assets/logo.png'; // Import logo
|
import logo from '../assets/logo.png'; // Import logo
|
||||||
|
|
||||||
interface PromptTemplate {
|
interface PromptTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
keywords?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryItem {
|
interface HistoryItem {
|
||||||
@@ -32,7 +34,14 @@ interface RecorderProps {
|
|||||||
onDeleteHistory: (id: string) => void;
|
onDeleteHistory: (id: string) => void;
|
||||||
onLoadHistory: (item: HistoryItem) => void;
|
onLoadHistory: (item: HistoryItem) => void;
|
||||||
savePath: string | null;
|
savePath: string | null;
|
||||||
|
|
||||||
onRecordingComplete: () => void;
|
onRecordingComplete: () => void;
|
||||||
|
autoStart?: boolean;
|
||||||
|
recordingSubject?: string;
|
||||||
|
onAutoStartHandled?: () => void;
|
||||||
|
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
|
||||||
|
selectedModel: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -43,16 +52,27 @@ interface AudioDevice {
|
|||||||
const Recorder: React.FC<RecorderProps> = ({
|
const Recorder: React.FC<RecorderProps> = ({
|
||||||
apiKey, productId, prompts,
|
apiKey, productId, prompts,
|
||||||
setTranscription, setSummary,
|
setTranscription, setSummary,
|
||||||
onSaveToHistory, savePath, onRecordingComplete
|
onSaveToHistory, savePath, onRecordingComplete,
|
||||||
|
onOpenSettings, addToast, selectedModel, onModelChange, ...props
|
||||||
}) => {
|
}) => {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isStopping, setIsStopping] = useState(false); // New lock state
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [status, setStatus] = useState<string>('Ready to record');
|
const [status, setStatus] = useState<string>('Ready to record');
|
||||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
// selectedModel is now a prop
|
||||||
|
const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice');
|
||||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||||
|
const [lastSpeechTime, setLastSpeechTime] = useState<number>(Date.now());
|
||||||
|
const [silenceDuration, setSilenceDuration] = useState(0);
|
||||||
|
|
||||||
|
// Filtered devices based on mode
|
||||||
|
const filteredDevices = devices.filter(d => {
|
||||||
|
const isVirtual = d.name.toLowerCase().includes('hearbit') || d.name.toLowerCase().includes('blackhole');
|
||||||
|
return recordingMode === 'meeting' ? isVirtual : !isVirtual;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDevices();
|
loadDevices();
|
||||||
@@ -95,12 +115,21 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
setDevices(aliasedDevs);
|
setDevices(aliasedDevs);
|
||||||
|
|
||||||
// Select Hearbit mic by default if available and no selection made
|
// Select Hearbit mic by default if available and no selection made
|
||||||
|
// Smart Auto-select based on mode
|
||||||
if (!selectedDevice) {
|
if (!selectedDevice) {
|
||||||
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
|
// Prioritize "Hearbit Audio" (Aggregate) over "Hearbit Virtual Mic" (BlackHole)
|
||||||
if (vb) {
|
const aggregateDev = aliasedDevs.find(d => d.name === 'Hearbit Audio');
|
||||||
setSelectedDevice(vb.id);
|
const virtualDev = aliasedDevs.find(d => d.name.includes('Hearbit Virtual'));
|
||||||
} else if (aliasedDevs.length > 0) {
|
|
||||||
setSelectedDevice(aliasedDevs[0].id);
|
if (aggregateDev) {
|
||||||
|
setRecordingMode('meeting');
|
||||||
|
setSelectedDevice(aggregateDev.id);
|
||||||
|
} else if (virtualDev) {
|
||||||
|
setRecordingMode('meeting');
|
||||||
|
setSelectedDevice(virtualDev.id);
|
||||||
|
} else {
|
||||||
|
setRecordingMode('voice');
|
||||||
|
if (aliasedDevs.length > 0) setSelectedDevice(aliasedDevs[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -113,26 +142,114 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
await invoke('open_audio_midi_setup');
|
await invoke('open_audio_midi_setup');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
addToast('Failed to open Audio Setup', 'error');
|
||||||
setStatus('Failed to open Audio Setup');
|
setStatus('Failed to open Audio Setup');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async (deviceIdOverride?: string) => {
|
||||||
try {
|
try {
|
||||||
setStatus('Starting...');
|
setStatus('Starting...');
|
||||||
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
setStatus('Starting...');
|
||||||
|
// Check override or state
|
||||||
|
const targetDeviceId = deviceIdOverride || selectedDevice;
|
||||||
|
|
||||||
|
// Pass customFilename (camelCase key maps to snake_case in Rust automatically or we need to check Tauri mapping, usually it maps camel to camel? Rust expects snake. Let's use snake_case in invoke args to be safe)
|
||||||
|
await invoke('start_recording', { deviceId: targetDeviceId, savePath: savePath || null, customFilename: props.recordingSubject || null });
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
setTranscription('');
|
setTranscription('');
|
||||||
setSummary('');
|
setSummary('');
|
||||||
setStatus('Recording...');
|
setStatus('Recording...');
|
||||||
|
addToast('Recording started', 'success', 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setStatus(`Error: ${e}`);
|
setStatus(`Error: ${e}`);
|
||||||
|
addToast(`Error starting recording: ${e}`, 'error');
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// VAD & Auto-Stop Logic
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: () => void;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
unlisten = await listen<{ is_speech: boolean, probability: number }>('vad-event', (event) => {
|
||||||
|
if (event.payload.is_speech) {
|
||||||
|
setLastSpeechTime(Date.now());
|
||||||
|
setSilenceDuration(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRecording && !isPaused) {
|
||||||
|
setupListener();
|
||||||
|
setLastSpeechTime(Date.now()); // Reset on start
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (isRecording && !isPaused) {
|
||||||
|
const diff = (Date.now() - lastSpeechTime) / 1000;
|
||||||
|
setSilenceDuration(diff);
|
||||||
|
|
||||||
|
// Auto-stop after 30 seconds of silence
|
||||||
|
if (diff > 30 && !isStopping) { // Check lock
|
||||||
|
console.log("Auto-stopping due to silence");
|
||||||
|
addToast("Auto-stopping (Silence detected)", "info", 3000);
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isRecording, isPaused, lastSpeechTime]);
|
||||||
|
|
||||||
|
// Handle Auto Start Prop
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.autoStart && !isRecording && devices.length > 0) {
|
||||||
|
// Force meeting mode for auto-joins
|
||||||
|
if (recordingMode !== 'meeting') {
|
||||||
|
setRecordingMode('meeting');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best device (Race condition fix: we can't rely on selectedDevice state update being instant)
|
||||||
|
const aggregateDev = devices.find(d => d.name === 'Hearbit Audio');
|
||||||
|
const virtualDev = devices.find(d => d.name.includes('Hearbit Virtual'));
|
||||||
|
const bestDevice = aggregateDev || virtualDev;
|
||||||
|
|
||||||
|
if (bestDevice) {
|
||||||
|
setSelectedDevice(bestDevice.id); // Update UI state for consistency
|
||||||
|
console.log("Auto-starting with device:", bestDevice.name);
|
||||||
|
startRecording(bestDevice.id); // Pass ID directly
|
||||||
|
} else {
|
||||||
|
console.warn("Auto-start: No meeting device found, trying default.");
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onAutoStartHandled) {
|
||||||
|
props.onAutoStartHandled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props.autoStart, devices]);
|
||||||
|
|
||||||
|
// Handle Custom Event (Legacy/Fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStartReq = () => {
|
||||||
|
if (!isRecording) {
|
||||||
|
if (recordingMode !== 'meeting') {
|
||||||
|
setRecordingMode('meeting');
|
||||||
|
}
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('start-recording-req', handleStartReq);
|
||||||
|
return () => window.removeEventListener('start-recording-req', handleStartReq);
|
||||||
|
}, [isRecording, recordingMode]);
|
||||||
|
|
||||||
const togglePause = async () => {
|
const togglePause = async () => {
|
||||||
try {
|
try {
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
@@ -150,12 +267,18 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopRecording = async () => {
|
const stopRecording = async () => {
|
||||||
|
if (isStopping) return;
|
||||||
|
setIsStopping(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
setStatus('Processing...');
|
setStatus('Processing...');
|
||||||
const filePath = await invoke<string>('stop_recording');
|
const filePath = await invoke<string>('stop_recording');
|
||||||
|
|
||||||
|
// Wait a moment for file flush (safety)
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
setStatus('Transcribing (Infomaniak Whisper)...');
|
setStatus('Transcribing (Infomaniak Whisper)...');
|
||||||
const transText = await invoke<string>('transcribe_audio', {
|
const transText = await invoke<string>('transcribe_audio', {
|
||||||
filePath,
|
filePath,
|
||||||
@@ -172,8 +295,40 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find selected prompt content
|
// Find selected prompt content - SMART SELECTION
|
||||||
const activePrompt = prompts.find(p => p.id === selectedPromptId);
|
let activePrompt = prompts.find(p => p.id === selectedPromptId);
|
||||||
|
|
||||||
|
// Smart Auto-Select based on keywords
|
||||||
|
const lowerText = transText.toLowerCase();
|
||||||
|
let bestMatchId = selectedPromptId;
|
||||||
|
let maxMatches = 0;
|
||||||
|
|
||||||
|
for (const p of prompts) {
|
||||||
|
if (!p.keywords) continue;
|
||||||
|
let matches = 0;
|
||||||
|
for (const kw of p.keywords) {
|
||||||
|
if (lowerText.includes(kw.toLowerCase())) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches > maxMatches) {
|
||||||
|
maxMatches = matches;
|
||||||
|
bestMatchId = p.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatchId !== selectedPromptId) {
|
||||||
|
const newPrompt = prompts.find(p => p.id === bestMatchId);
|
||||||
|
if (newPrompt) {
|
||||||
|
console.log(`Smart Select: Switched to '${newPrompt.name}' with ${maxMatches} matches.`);
|
||||||
|
setStatus(`Smart Select: Using "${newPrompt.name}"...`);
|
||||||
|
addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'success', 4000);
|
||||||
|
activePrompt = newPrompt;
|
||||||
|
// Optional: Update UI selection? setSelectedPromptId(bestMatchId);
|
||||||
|
// Let's verify with user preference? For now, we override as "Magic".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
|
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
|
||||||
|
|
||||||
setStatus(`Summarizing (${selectedModel})...`);
|
setStatus(`Summarizing (${selectedModel})...`);
|
||||||
@@ -190,51 +345,60 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
onSaveToHistory(transText, sumText);
|
onSaveToHistory(transText, sumText);
|
||||||
|
|
||||||
setStatus('Done!');
|
setStatus('Done!');
|
||||||
|
addToast('Transcription & Summary complete!', 'success', 4000);
|
||||||
onRecordingComplete(); // Auto-switch tab
|
onRecordingComplete(); // Auto-switch tab
|
||||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setStatus(`Error: ${e}`);
|
setStatus(`Error: ${e}`);
|
||||||
|
addToast(`Error processing: ${e}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setIsStopping(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full bg-background relative">
|
<div className="flex flex-col w-full h-full bg-background relative">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header - Reduced padding */}
|
||||||
<div className="w-full flex justify-center items-center p-6 shrink-0">
|
<div className="w-full flex justify-center items-center p-4 shrink-0">
|
||||||
<img src={logo} alt="Logo" className="h-12 object-contain" />
|
<img src={logo} alt="Logo" className="h-10 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content - Reduced spacing */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
|
||||||
<div className="mb-6 relative shrink-0">
|
<div className="mb-4 relative shrink-0">
|
||||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
|
<div className={`w-24 h-24 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div className={`w-24 h-24 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
|
||||||
<Mic size={40} className="text-white animate-bounce" />
|
<Mic size={32} className="text-white animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
<div className="w-16 h-16 rounded-full bg-primary flex items-center justify-center">
|
||||||
<Mic size={40} className="text-primary-foreground" />
|
<Mic size={32} className="text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-2 text-foreground">
|
<h1 className="text-xl font-bold mb-1 text-foreground">
|
||||||
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
|
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
<p className="text-muted-foreground mb-4 text-center text-xs h-5">
|
||||||
{status}
|
{status}
|
||||||
|
{isRecording && !isPaused && silenceDuration > 10 && (
|
||||||
|
<span className="block text-xs text-yellow-500 mt-0.5 opacity-80">
|
||||||
|
Silence detected: {Math.floor(silenceDuration)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
<div className="w-full max-w-sm space-y-3 mb-4 shrink-0">
|
||||||
{!isRecording ? (
|
{!isRecording ? (
|
||||||
<button
|
<button
|
||||||
onClick={startRecording}
|
onClick={() => startRecording()}
|
||||||
disabled={!apiKey || !productId}
|
disabled={!apiKey || !productId}
|
||||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
className="w-full py-3 text-base font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
||||||
</button>
|
</button>
|
||||||
@@ -260,31 +424,56 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
<div>
|
</div>
|
||||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
|
||||||
Input Device
|
{/* INPUT DEVICE SECTION */}
|
||||||
</label>
|
<div className="col-span-2">
|
||||||
<select
|
<div className="flex bg-secondary p-1 rounded-lg mb-2">
|
||||||
value={selectedDevice}
|
<button
|
||||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
onClick={() => { setRecordingMode('voice'); setSelectedDevice(''); }}
|
||||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'voice' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
disabled={isRecording}
|
|
||||||
>
|
>
|
||||||
{devices.map(d => (
|
<Headphones size={14} /> Voice Memo
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
</button>
|
||||||
))}
|
<button
|
||||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
onClick={() => { setRecordingMode('meeting'); setSelectedDevice(''); }}
|
||||||
</select>
|
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'meeting' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
<Users size={14} /> Meeting
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedDevice}
|
||||||
|
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||||
|
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={isRecording}
|
||||||
|
>
|
||||||
|
{filteredDevices.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
{filteredDevices.length === 0 && (
|
||||||
|
<option value="">
|
||||||
|
{recordingMode === 'meeting' ? 'No Meeting Device (Create in Settings)' : 'No Microphone Found'}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||||
LLM Model
|
LLM Model
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
onChange={(e) => {
|
||||||
|
onModelChange(e.target.value);
|
||||||
|
// localStorage handled in App.tsx
|
||||||
|
}}
|
||||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||||
disabled={isRecording}
|
// Allow changing model while recording (since it's used for summary after)
|
||||||
|
disabled={false}
|
||||||
>
|
>
|
||||||
{availableModels.map(m => (
|
{availableModels.map(m => (
|
||||||
<option key={m.id} value={m.id}>{m.name}</option>
|
<option key={m.id} value={m.id}>{m.name}</option>
|
||||||
@@ -301,7 +490,8 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
value={selectedPromptId}
|
value={selectedPromptId}
|
||||||
onChange={(e) => setSelectedPromptId(e.target.value)}
|
onChange={(e) => setSelectedPromptId(e.target.value)}
|
||||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||||
disabled={isRecording || prompts.length === 0}
|
// Allow changing template while recording
|
||||||
|
disabled={prompts.length === 0}
|
||||||
>
|
>
|
||||||
{prompts.map(p => (
|
{prompts.map(p => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
@@ -311,11 +501,19 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||||
|
{recordingMode === 'meeting' && filteredDevices.length === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 w-full text-center border border-primary/20 rounded p-2 mb-2 font-semibold"
|
||||||
|
>
|
||||||
|
🪄 Create "Hearbit Audio" Device
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={openAudioSetup}
|
onClick={openAudioSetup}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
||||||
>
|
>
|
||||||
Open Audio MIDI Setup (Configure Multi-Output)
|
Open Audio MIDI Setup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,56 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
|
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff, Mail, FileText, ScrollText } from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { save, open } from '@tauri-apps/plugin-dialog';
|
||||||
|
// Removed writeTextFile as we use invoke 'save_text_file'
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { encryptData, decryptData } from '../utils/backup';
|
import { encryptData, decryptData } from '../utils/backup';
|
||||||
import { PromptTemplate } from '../App';
|
import EmailTemplateEditor from './EmailTemplateEditor';
|
||||||
|
|
||||||
|
import { PromptTemplate, EmailTemplate } from '../App';
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
savePath: string;
|
savePath: string;
|
||||||
prompts: PromptTemplate[];
|
prompts: PromptTemplate[];
|
||||||
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void;
|
emailTemplates: EmailTemplate[];
|
||||||
|
smtpConfig: SmtpConfig;
|
||||||
|
azureConfig: AzureConfig;
|
||||||
|
onSave: (
|
||||||
|
apiKey: string,
|
||||||
|
productId: string,
|
||||||
|
prompts: PromptTemplate[],
|
||||||
|
savePath: string,
|
||||||
|
smtp: SmtpConfig,
|
||||||
|
azure: AzureConfig,
|
||||||
|
emailTemplates: EmailTemplate[]
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => {
|
export interface SmtpConfig {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AzureConfig {
|
||||||
|
clientId: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose, ...props }) => {
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localProductId, setLocalProductId] = useState(productId);
|
const [localProductId, setLocalProductId] = useState(productId);
|
||||||
const [localSavePath, setLocalSavePath] = useState(savePath);
|
const [localSavePath, setLocalSavePath] = useState(savePath);
|
||||||
|
|
||||||
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
|
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
|
||||||
|
const [localEmailTemplates, setLocalEmailTemplates] = useState<EmailTemplate[]>(props.emailTemplates); // New state
|
||||||
|
const [localSmtp, setLocalSmtp] = useState<SmtpConfig>(props.smtpConfig);
|
||||||
|
const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig);
|
||||||
const [statusIdx, setStatusIdx] = useState<string | null>(null);
|
const [statusIdx, setStatusIdx] = useState<string | null>(null);
|
||||||
|
|
||||||
// Backup & Restore State
|
// Backup & Restore State
|
||||||
@@ -26,6 +59,48 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||||
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Email Template Editor State
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||||
|
const [isEmailEditorOpen, setIsEmailEditorOpen] = useState(false);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'prompts' | 'email' | 'backup' | 'logs'>('general');
|
||||||
|
const [logs, setLogs] = useState<string>('Loading logs...');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'logs') {
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
try {
|
||||||
|
const content = await invoke<string>('read_log_file');
|
||||||
|
setLogs(content);
|
||||||
|
} catch (e) {
|
||||||
|
setLogs(`Failed to load logs: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLogs = async () => {
|
||||||
|
try {
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: `hearbit_logs_${new Date().toISOString().slice(0, 10)}.log`,
|
||||||
|
filters: [{
|
||||||
|
name: 'Log File',
|
||||||
|
extensions: ['log', 'txt']
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
await invoke('save_text_file', { path: filePath, content: logs });
|
||||||
|
setStatusIdx(`Logs exported to: ${filePath}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatusIdx(`Failed to export logs: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
|
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
|
||||||
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
||||||
};
|
};
|
||||||
@@ -38,8 +113,26 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
setLocalPrompts(localPrompts.filter(p => p.id !== id));
|
setLocalPrompts(localPrompts.filter(p => p.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveEmailTemplate = (template: EmailTemplate) => {
|
||||||
|
const exists = localEmailTemplates.find(t => t.id === template.id);
|
||||||
|
if (exists) {
|
||||||
|
setLocalEmailTemplates(localEmailTemplates.map(t => t.id === template.id ? template : t));
|
||||||
|
} else {
|
||||||
|
setLocalEmailTemplates([...localEmailTemplates, template]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEmailEditor = (template: EmailTemplate | null) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
setIsEmailEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEmailTemplate = (id: string) => {
|
||||||
|
setLocalEmailTemplates(localEmailTemplates.filter(t => t.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave(localApiKey, localProductId, localPrompts, localSavePath);
|
onSave(localApiKey, localProductId, localPrompts, localSavePath, localSmtp, localAzure, localEmailTemplates);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,22 +162,28 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
apiKey: localApiKey,
|
apiKey: localApiKey,
|
||||||
productId: localProductId,
|
productId: localProductId,
|
||||||
prompts: localPrompts,
|
prompts: localPrompts,
|
||||||
savePath: localSavePath
|
savePath: localSavePath,
|
||||||
|
smtp: localSmtp,
|
||||||
|
azure: localAzure
|
||||||
};
|
};
|
||||||
const encrypted = await encryptData(data, backupPassword);
|
const encrypted = await encryptData(data, backupPassword);
|
||||||
const blob = new Blob([encrypted], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const filePath = await save({
|
||||||
const a = document.createElement('a');
|
defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`,
|
||||||
a.href = url;
|
filters: [{
|
||||||
a.download = `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`;
|
name: 'Hearbit Config',
|
||||||
document.body.appendChild(a);
|
extensions: ['conf']
|
||||||
a.click();
|
}]
|
||||||
document.body.removeChild(a);
|
});
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
setStatusIdx('Configuration exported successfully!');
|
if (filePath) {
|
||||||
|
// Use backend invoke to write file (bypasses fs scope issues)
|
||||||
|
await invoke('save_text_file', { path: filePath, content: encrypted });
|
||||||
|
setStatusIdx(`Configuration exported to: ${filePath}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setStatusIdx('Export failed.');
|
setStatusIdx(`Export failed: ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,8 +218,12 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
const data = await decryptData(importFileContent, backupPassword);
|
const data = await decryptData(importFileContent, backupPassword);
|
||||||
if (data.apiKey) setLocalApiKey(data.apiKey);
|
if (data.apiKey) setLocalApiKey(data.apiKey);
|
||||||
if (data.productId) setLocalProductId(data.productId);
|
if (data.productId) setLocalProductId(data.productId);
|
||||||
|
|
||||||
if (data.prompts) setLocalPrompts(data.prompts);
|
if (data.prompts) setLocalPrompts(data.prompts);
|
||||||
|
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
|
||||||
if (data.savePath) setLocalSavePath(data.savePath);
|
if (data.savePath) setLocalSavePath(data.savePath);
|
||||||
|
if (data.smtp) setLocalSmtp(data.smtp);
|
||||||
|
if (data.azure) setLocalAzure(data.azure);
|
||||||
|
|
||||||
setStatusIdx('Configuration imported! Click Save to apply.');
|
setStatusIdx('Configuration imported! Click Save to apply.');
|
||||||
setIsImportModalOpen(false);
|
setIsImportModalOpen(false);
|
||||||
@@ -131,6 +234,25 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateDevice = async () => {
|
||||||
|
try {
|
||||||
|
setStatusIdx('Creating Hearbit Audio device...');
|
||||||
|
await invoke('create_hearbit_audio_device');
|
||||||
|
setStatusIdx('Success! "Hearbit Audio" device created.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatusIdx(`Error: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: 'General', icon: <Save size={14} /> },
|
||||||
|
{ id: 'prompts', label: 'Prompts', icon: <FileText size={14} /> },
|
||||||
|
{ id: 'email', label: 'Email', icon: <Mail size={14} /> },
|
||||||
|
{ id: 'backup', label: 'Backup', icon: <Lock size={14} /> },
|
||||||
|
{ id: 'logs', label: 'Logs', icon: <ScrollText size={14} /> },
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
|
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
|
||||||
{/* Import Password Modal */}
|
{/* Import Password Modal */}
|
||||||
@@ -177,144 +299,354 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 border-b border-border/40 bg-secondary/20 flex justify-between items-center">
|
{/* Email Template Editor Modal */}
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Settings</span>
|
<EmailTemplateEditor
|
||||||
<button onClick={handleSave} className="flex items-center gap-1 text-primary hover:text-primary/80 transition-colors font-semibold">
|
isOpen={isEmailEditorOpen}
|
||||||
<Save size={16} /> Save
|
onClose={() => setIsEmailEditorOpen(false)}
|
||||||
</button>
|
template={editingTemplate}
|
||||||
</div>
|
onSave={handleSaveEmailTemplate}
|
||||||
|
smtpConfig={localSmtp}
|
||||||
|
addToast={(msg, type) => setStatusIdx(`${type === 'error' ? 'Error' : 'Success'}: ${msg}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
<div className="flex flex-col border-b border-border/40 bg-secondary/10">
|
||||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
<div className="p-4 flex justify-between items-center">
|
||||||
<h3 className="text-foreground font-semibold flex items-center gap-2">General</h3>
|
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
|
||||||
<div>
|
<button onClick={handleSave} className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded font-semibold hover:bg-primary/90 transition-all active:scale-95 text-xs">
|
||||||
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
|
<Save size={16} /> Save Changes
|
||||||
<input
|
</button>
|
||||||
id="apiKey"
|
|
||||||
type="password"
|
|
||||||
value={localApiKey}
|
|
||||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
|
||||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
|
|
||||||
<input
|
|
||||||
id="productId"
|
|
||||||
type="text"
|
|
||||||
value={localProductId}
|
|
||||||
onChange={(e) => setLocalProductId(e.target.value)}
|
|
||||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
id="savePath"
|
|
||||||
type="text"
|
|
||||||
value={localSavePath}
|
|
||||||
onChange={(e) => setLocalSavePath(e.target.value)}
|
|
||||||
placeholder="/Users/username/Desktop/Recordings"
|
|
||||||
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSelectFolder}
|
|
||||||
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-colors"
|
|
||||||
title="Pick Folder"
|
|
||||||
>
|
|
||||||
<FolderOpen size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
{/* Tabs */}
|
||||||
<h3 className="text-foreground font-semibold flex items-center gap-2">
|
<div className="flex px-4 gap-2">
|
||||||
<Lock size={16} /> Backup & Restore
|
{tabs.map(tab => (
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Export your settings (keys, prompts, path) to an encrypted file.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={backupPassword}
|
|
||||||
onChange={(e) => setBackupPassword(e.target.value)}
|
|
||||||
placeholder="Encryption Password"
|
|
||||||
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
key={tab.id}
|
||||||
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === tab.id ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
{tab.icon} {tab.label}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
|
|
||||||
>
|
|
||||||
<Download size={14} /> Export Config
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={triggerImport}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
|
|
||||||
>
|
|
||||||
<Upload size={14} /> Import Config
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="import-file-input"
|
|
||||||
accept=".conf"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="text-foreground font-semibold">Prompts</h3>
|
|
||||||
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded hover:bg-primary/90">
|
|
||||||
+ Add Prompt
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{localPrompts.map((prompt) => (
|
|
||||||
<div key={prompt.id} className="space-y-2 p-3 bg-secondary/30 rounded border border-border/50 relative group">
|
|
||||||
<button
|
|
||||||
onClick={() => removePrompt(prompt.id)}
|
|
||||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
|
||||||
>
|
|
||||||
<EyeOff size={12} className="inline mr-1" /> Remove
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={prompt.name}
|
|
||||||
onChange={(e) => handlePromptChange(prompt.id, 'name', e.target.value)}
|
|
||||||
className="w-full p-1 bg-transparent border-b border-border/50 focus:border-primary outline-none font-semibold text-sm"
|
|
||||||
placeholder="Prompt Name"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={prompt.content}
|
|
||||||
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)}
|
|
||||||
className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[60px]"
|
|
||||||
placeholder="Prompt Content"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{statusIdx && (
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className={`p-2 text-xs rounded border ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
|
{activeTab === 'general' && (
|
||||||
{statusIdx}
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">Application Keys</h3>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
|
||||||
|
<input
|
||||||
|
id="apiKey"
|
||||||
|
type="password"
|
||||||
|
value={localApiKey}
|
||||||
|
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
|
||||||
|
<input
|
||||||
|
id="productId"
|
||||||
|
type="text"
|
||||||
|
value={localProductId}
|
||||||
|
onChange={(e) => setLocalProductId(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">Storage</h3>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="savePath"
|
||||||
|
type="text"
|
||||||
|
value={localSavePath}
|
||||||
|
onChange={(e) => setLocalSavePath(e.target.value)}
|
||||||
|
placeholder="/Users/username/Desktop/Recordings"
|
||||||
|
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFolder}
|
||||||
|
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-all active:scale-95"
|
||||||
|
title="Pick Folder"
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">System Intergration</h3>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-secondary/20 rounded border border-border/50">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">Hearbit Audio Device</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Required for recording system audio (Teams, Zoom, etc.)</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateDevice}
|
||||||
|
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all active:scale-95 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🪄</span> Create / Repair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'prompts' && (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<div className="flex justify-between items-center border-b border-border pb-2">
|
||||||
|
<h3 className="text-foreground font-semibold">AI Prompts</h3>
|
||||||
|
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95">
|
||||||
|
+ Add Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{localPrompts.map((prompt) => (
|
||||||
|
<div key={prompt.id} className="space-y-2 p-4 bg-secondary/10 rounded border border-border/50 relative group">
|
||||||
|
<button
|
||||||
|
onClick={() => removePrompt(prompt.id)}
|
||||||
|
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<EyeOff size={14} /> Remove
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={prompt.name}
|
||||||
|
onChange={(e) => handlePromptChange(prompt.id, 'name', e.target.value)}
|
||||||
|
className="w-full p-1 bg-transparent border-b border-border/50 focus:border-primary outline-none font-semibold text-sm"
|
||||||
|
placeholder="Prompt Name"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={prompt.content}
|
||||||
|
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)}
|
||||||
|
className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[100px] font-mono"
|
||||||
|
placeholder="Prompt Content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'email' && (
|
||||||
|
<div className="space-y-8 max-w-2xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center border-b border-border pb-2">
|
||||||
|
<h3 className="text-foreground font-semibold">Email Templates</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => openEmailEditor(null)}
|
||||||
|
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded hover:bg-primary/90 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
+ Add Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{localEmailTemplates.map((template) => (
|
||||||
|
<div key={template.id} className="flex justify-between items-center p-4 bg-secondary/10 rounded border border-border/50 group hover:border-border/80 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
|
<div className="font-semibold text-sm truncate">{template.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{template.subject}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => openEmailEditor(template)}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEmailTemplate(template.id)}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{localEmailTemplates.length === 0 && (
|
||||||
|
<div className="text-center p-8 text-muted-foreground text-xs italic border border-dashed border-border rounded">
|
||||||
|
No templates created yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">SMTP Configuration</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">SMTP Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSmtp.host}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, host: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
placeholder="smtp.office365.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Port</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSmtp.port}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, port: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Sender Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSmtp.sender}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, sender: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Sender Name (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSmtp.senderName}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, senderName: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
placeholder="Hearbit Assistant"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSmtp.user}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, user: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={localSmtp.pass}
|
||||||
|
onChange={(e) => setLocalSmtp({ ...localSmtp, pass: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">Microsoft 365 (Azure AD)</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Optional configuration for advanced MS Graph integrations.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-foreground">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localAzure.clientId}
|
||||||
|
onChange={(e) => setLocalAzure({ ...localAzure, clientId: e.target.value })}
|
||||||
|
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||||
|
placeholder="Application (client) ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'backup' && (
|
||||||
|
<div className="space-y-6 max-w-xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-foreground font-semibold border-b border-border pb-2">Configuration Backup</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Securely export your settings, including API keys and prompts. You must set a password to encrypt the backup file.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
|
||||||
|
Encryption Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={backupPassword}
|
||||||
|
onChange={(e) => setBackupPassword(e.target.value)}
|
||||||
|
placeholder="Enter a strong password"
|
||||||
|
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-2 top-8 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
|
||||||
|
>
|
||||||
|
<Download size={18} /> Export Config
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={triggerImport}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all font-semibold active:scale-95"
|
||||||
|
>
|
||||||
|
<Upload size={18} /> Import Config
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="import-file-input"
|
||||||
|
accept=".conf"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<div className="space-y-4 max-w-4xl h-full flex flex-col">
|
||||||
|
<div className="flex justify-between items-center border-b border-border pb-2">
|
||||||
|
<h3 className="text-foreground font-semibold">Application Logs</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveLogs}
|
||||||
|
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Download size={12} /> Export Logs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadLogs}
|
||||||
|
className="text-xs bg-secondary hover:bg-secondary/80 px-2 py-1 rounded border border-border transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Refresh Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-black text-white p-4 rounded font-mono text-xs overflow-auto whitespace-pre-wrap leading-relaxed shadow-inner">
|
||||||
|
{logs}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{
|
||||||
|
statusIdx && (
|
||||||
|
<div className={`p-2 text-xs text-center border-t ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
|
||||||
|
{statusIdx}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Mic, Terminal, FileText } from 'lucide-react';
|
import { Mic, FileText, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
interface TabsProps {
|
interface TabsProps {
|
||||||
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings';
|
currentTab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history';
|
||||||
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void;
|
onTabChange: (tab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||||
@@ -23,15 +24,20 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
|||||||
Transcription
|
Transcription
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onTabChange('logs')}
|
onClick={() => onTabChange('meetings')}
|
||||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${currentTab === 'logs'
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'meetings' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||||
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Terminal size={14} />
|
<Calendar size={16} />
|
||||||
Logs
|
Meetings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onTabChange('history')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'history' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Settings could be a tab, but often better as an icon elsewhere, however sticking to the 'tab' request */}
|
{/* Settings could be a tab, but often better as an icon elsewhere, however sticking to the 'tab' request */}
|
||||||
{/* The user didn't explicitly ask for settings tab, but we need a way to get there. Let's keep it here for now or maybe just an icon?
|
{/* The user didn't explicitly ask for settings tab, but we need a way to get there. Let's keep it here for now or maybe just an icon?
|
||||||
The prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".
|
The prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".
|
||||||
|
|||||||
@@ -1,15 +1,84 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Copy, Check, Mail, RefreshCw, Wand2 } from 'lucide-react';
|
||||||
|
import EmailPreviewModal from './EmailPreviewModal';
|
||||||
|
import { SmtpConfig } from './Settings';
|
||||||
|
import { ToastType } from './ui/Toast';
|
||||||
|
import { PromptTemplate, EmailTemplate } from '../App';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
interface TranscriptionViewProps {
|
interface TranscriptionViewProps {
|
||||||
transcription: string;
|
transcription: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
smtpConfig: SmtpConfig;
|
||||||
|
apiKey: string;
|
||||||
|
productId: string;
|
||||||
|
prompts: PromptTemplate[];
|
||||||
|
emailTemplates: EmailTemplate[];
|
||||||
|
onUpdateSummary: (newSummary: string) => void;
|
||||||
|
addToast: (message: string, type: ToastType, duration?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, summary }) => {
|
const TranscriptionView: React.FC<TranscriptionViewProps> = ({
|
||||||
|
transcription, summary, smtpConfig, apiKey, productId, prompts, emailTemplates, onUpdateSummary, addToast
|
||||||
|
}) => {
|
||||||
const [copiedTrans, setCopiedTrans] = useState(false);
|
const [copiedTrans, setCopiedTrans] = useState(false);
|
||||||
const [copiedSum, setCopiedSum] = useState(false);
|
const [copiedSum, setCopiedSum] = useState(false);
|
||||||
|
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Regenerate State
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
const [showRegenOptions, setShowRegenOptions] = useState(false);
|
||||||
|
const [regenModel, setRegenModel] = useState<string>('mixtral');
|
||||||
|
const [regenPromptId, setRegenPromptId] = useState<string>('');
|
||||||
|
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showRegenOptions && availableModels.length === 0) {
|
||||||
|
loadModels();
|
||||||
|
}
|
||||||
|
if (prompts.length > 0 && !regenPromptId) {
|
||||||
|
setRegenPromptId(prompts[0].id);
|
||||||
|
}
|
||||||
|
}, [showRegenOptions]);
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
||||||
|
if (models && models.length > 0) {
|
||||||
|
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setAvailableModels(models);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load models:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
if (!transcription || !apiKey || !productId) return;
|
||||||
|
|
||||||
|
setIsRegenerating(true);
|
||||||
|
try {
|
||||||
|
const prompt = prompts.find(p => p.id === regenPromptId)?.content || "Summarize this.";
|
||||||
|
|
||||||
|
const newSummary = await invoke<string>('summarize_text', {
|
||||||
|
text: transcription,
|
||||||
|
apiKey,
|
||||||
|
productId,
|
||||||
|
prompt,
|
||||||
|
model: regenModel
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpdateSummary(newSummary);
|
||||||
|
addToast('Summary regenerated!', 'success');
|
||||||
|
setShowRegenOptions(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
addToast(`Regeneration failed: ${e}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopy = async (text: string, isSummary: boolean) => {
|
const handleCopy = async (text: string, isSummary: boolean) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -56,15 +125,74 @@ const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, su
|
|||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center shrink-0">
|
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center shrink-0">
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">AI Summary</span>
|
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">AI Summary</span>
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(summary, true)}
|
<div className="flex items-center gap-2">
|
||||||
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
|
{/* Regenerate Trigger */}
|
||||||
disabled={!summary}
|
<button
|
||||||
>
|
onClick={() => setShowRegenOptions(!showRegenOptions)}
|
||||||
{copiedSum ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
disabled={!transcription || !apiKey}
|
||||||
{copiedSum ? 'Copied' : 'Copy'}
|
className={`text-xs flex items-center gap-1 transition-colors px-2 py-1 rounded ${showRegenOptions ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary text-muted-foreground hover:text-foreground'}`}
|
||||||
</button>
|
title="Regenerate Summary"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={isRegenerating ? "animate-spin" : ""} />
|
||||||
|
{showRegenOptions ? 'Close' : 'Redo'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-border/50 mx-1"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEmailModalOpen(true)}
|
||||||
|
disabled={!summary}
|
||||||
|
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
|
||||||
|
title="Send via Email"
|
||||||
|
>
|
||||||
|
<Mail size={14} /> Email
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(summary, true)}
|
||||||
|
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
|
||||||
|
disabled={!summary}
|
||||||
|
>
|
||||||
|
{copiedSum ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||||
|
{copiedSum ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Regenerate Panel */}
|
||||||
|
{showRegenOptions && (
|
||||||
|
<div className="p-3 bg-secondary/30 border-b border-border/40 grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end animate-in slide-in-from-top-2 duration-200">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-muted-foreground block mb-1">Model</label>
|
||||||
|
<select
|
||||||
|
value={regenModel}
|
||||||
|
onChange={(e) => setRegenModel(e.target.value)}
|
||||||
|
className="w-full text-xs p-1.5 rounded border border-border bg-background"
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 && <option value="mixtral">Loading...</option>}
|
||||||
|
{availableModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-muted-foreground block mb-1">Template</label>
|
||||||
|
<select
|
||||||
|
value={regenPromptId}
|
||||||
|
onChange={(e) => setRegenPromptId(e.target.value)}
|
||||||
|
className="w-full text-xs p-1.5 rounded border border-border bg-background"
|
||||||
|
>
|
||||||
|
{prompts.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded font-semibold hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2 h-[30px]"
|
||||||
|
>
|
||||||
|
<Wand2 size={12} /> {isRegenerating ? 'Running...' : 'Generate New'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-y-auto p-4 bg-secondary/10 prose prose-sm max-w-none prose-p:text-foreground/90 prose-headings:text-foreground prose-strong:text-foreground prose-ul:text-foreground/90">
|
<div className="flex-1 overflow-y-auto p-4 bg-secondary/10 prose prose-sm max-w-none prose-p:text-foreground/90 prose-headings:text-foreground prose-strong:text-foreground prose-ul:text-foreground/90">
|
||||||
{summary ? (
|
{summary ? (
|
||||||
<ReactMarkdown>{summary}</ReactMarkdown>
|
<ReactMarkdown>{summary}</ReactMarkdown>
|
||||||
@@ -74,7 +202,18 @@ const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, su
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<EmailPreviewModal
|
||||||
|
isOpen={isEmailModalOpen}
|
||||||
|
onClose={() => setIsEmailModalOpen(false)}
|
||||||
|
initialRecipients={[]} // TODO: Pass attendees from meeting
|
||||||
|
initialSubject="Meeting Summary" // Default subject
|
||||||
|
initialBody={summary}
|
||||||
|
emailTemplates={emailTemplates} // Pass templates
|
||||||
|
smtpConfig={smtpConfig ? { ...smtpConfig, port: Number(smtpConfig.port) } : null}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
81
src/components/ui/Toast.tsx
Normal file
81
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
toast: ToastMessage;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine background color based on type
|
||||||
|
// Animate in
|
||||||
|
requestAnimationFrame(() => setIsVisible(true));
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => onClose(toast.id), 300); // Wait for animation
|
||||||
|
}, toast.duration || 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toast, onClose]);
|
||||||
|
|
||||||
|
const bgColors = {
|
||||||
|
success: 'bg-green-600',
|
||||||
|
error: 'bg-destructive',
|
||||||
|
info: 'bg-blue-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
success: <CheckCircle size={18} />,
|
||||||
|
error: <AlertCircle size={18} />,
|
||||||
|
info: <Info size={18} />
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white mb-2 transition-all duration-300 transform
|
||||||
|
${bgColors[toast.type]}
|
||||||
|
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||||
|
`}
|
||||||
|
style={{ minWidth: '300px', maxWidth: '400px' }}
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{icon[toast.type]}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsVisible(false); setTimeout(() => onClose(toast.id), 300); }}
|
||||||
|
className="text-white/80 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastContainer: React.FC<{ toasts: ToastMessage[], removeToast: (id: string) => void }> = ({ toasts, removeToast }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<Toast key={t.id} toast={t} onClose={removeToast} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastContainer;
|
||||||
Reference in New Issue
Block a user