feat: improve audio exclusion with smart matching and reliability fixes

This commit is contained in:
michael.borak
2026-01-25 16:37:58 +01:00
parent 69dc6b8fac
commit de504fbcb4
15 changed files with 2720 additions and 306 deletions

View File

@@ -65,6 +65,10 @@ function App() {
const [dailyBackupEnabled, setDailyBackupEnabled] = useState(() => localStorage.getItem('hearbit_daily_backup_enabled') === 'true');
const [dailyBackupPath, setDailyBackupPath] = useState(() => localStorage.getItem('hearbit_daily_backup_path') || '');
const [lastBackupDate, setLastBackupDate] = useState(() => localStorage.getItem('hearbit_last_backup_date') || '');
const [blockedApps, setBlockedApps] = useState<string[]>(() => {
const saved = localStorage.getItem('hearbit_blocked_apps');
return saved ? JSON.parse(saved) : [];
});
const handleModelChange = (model: string) => {
setSelectedModel(model);
@@ -233,7 +237,6 @@ Thanks!`
return saved ? JSON.parse(saved) : defaultEmailTemplates;
});
const handleSaveSettings = (
newApiKey: string,
newProductId: string,
@@ -243,7 +246,8 @@ Thanks!`
newAzure: AzureConfig,
newEmailTemplates: EmailTemplate[],
newDailyBackupEnabled: boolean,
newDailyBackupPath: string
newDailyBackupPath: string,
newBlockedApps: string[]
) => {
setApiKey(newApiKey);
setProductId(newProductId);
@@ -255,6 +259,7 @@ Thanks!`
setDailyBackupEnabled(newDailyBackupEnabled);
setDailyBackupPath(newDailyBackupPath);
setBlockedApps(newBlockedApps);
localStorage.setItem('hearbit_api_key', newApiKey);
localStorage.setItem('hearbit_product_id', newProductId);
@@ -266,6 +271,7 @@ Thanks!`
localStorage.setItem('hearbit_daily_backup_enabled', String(newDailyBackupEnabled));
localStorage.setItem('hearbit_daily_backup_path', newDailyBackupPath);
localStorage.setItem('hearbit_blocked_apps', JSON.stringify(newBlockedApps));
setView(lastTab);
};
@@ -477,6 +483,7 @@ Thanks!`
selectedModel={selectedModel}
onModelChange={handleModelChange}
isVisible={view === 'recorder'}
blockedApps={blockedApps}
/>
</div>
@@ -575,6 +582,7 @@ Thanks!`
setHistory(newHistory);
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
}}
blockedApps={blockedApps}
/>
)}
</div>

View File

@@ -43,6 +43,7 @@ interface RecorderProps {
selectedModel: string;
onModelChange: (model: string) => void;
isVisible: boolean;
blockedApps: string[];
}
interface AudioDevice {
@@ -54,7 +55,7 @@ const Recorder: React.FC<RecorderProps> = ({
apiKey, productId, prompts,
setTranscription, setSummary,
onSaveToHistory, savePath, onRecordingComplete,
onOpenSettings, addToast, selectedModel, onModelChange, ...props
onOpenSettings, addToast, selectedModel, onModelChange, blockedApps, ...props
}) => {
const [isRecording, setIsRecording] = useState(false);
const [isStopping, setIsStopping] = useState(false); // New lock state
@@ -164,7 +165,8 @@ const Recorder: React.FC<RecorderProps> = ({
savePath: savePath || null,
customFilename: props.recordingSubject || null,
waitForSpeech: autoStartEnabled, // Pass the toggle state
mode: recordingMode
mode: recordingMode,
excludedApps: blockedApps
});
setIsRecording(true);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Mail, FileText, ScrollText } from 'lucide-react';
import { Save, FolderOpen, Lock, Upload, Download, Mail, FileText, ScrollText, Headphones } from 'lucide-react';
import { save, open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import EmailTemplateEditor from './EmailTemplateEditor';
@@ -28,10 +28,12 @@ interface SettingsProps {
azure: AzureConfig,
emailTemplates: EmailTemplate[],
dailyBackupEnabled: boolean,
dailyBackupPath: string
dailyBackupPath: string,
blockedApps: string[]
) => void;
onHistoryUpdate: (history: any[]) => void;
onClose: () => void;
blockedApps: string[];
}
export interface SmtpConfig {
@@ -48,6 +50,30 @@ export interface AzureConfig {
tenantId: string;
}
const APP_PRESETS = [
{
category: 'Browsers',
apps: [
{ name: 'Safari', id: 'com.apple.Safari' },
{ name: 'Chrome', id: 'com.google.Chrome' },
{ name: 'Firefox', id: 'org.mozilla.firefox' },
{ name: 'Arc', id: 'company.thebrowser.Browser' },
{ name: 'Edge', id: 'com.microsoft.edgemac' },
{ name: 'Brave', id: 'com.brave.Browser' },
]
},
{
category: 'Music & Media',
apps: [
{ name: 'Apple Music', id: 'com.apple.Music' },
{ name: 'Spotify', id: 'com.spotify.client' },
{ name: 'Tidal', id: 'com.aspiro.Tidal' },
{ name: 'VLC', id: 'org.videolan.vlc' },
{ name: 'Podcasts', id: 'com.apple.podcasts' },
]
}
];
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose, ...props }) => {
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localProductId, setLocalProductId] = useState(productId);
@@ -59,21 +85,34 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig);
const [localDailyBackupEnabled, setLocalDailyBackupEnabled] = useState(props.dailyBackupEnabled);
const [localDailyBackupPath, setLocalDailyBackupPath] = useState(props.dailyBackupPath);
const [localBlockedApps, setLocalBlockedApps] = useState<string[]>(props.blockedApps);
const [runningApps, setRunningApps] = useState<{ name: string, bundle_id: string }[]>([]);
const [statusIdx, setStatusIdx] = 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 [activeTab, setActiveTab] = useState<'general' | 'prompts' | 'email' | 'backup' | 'logs' | 'apps'>('general');
const [logs, setLogs] = useState<string>('Loading logs...');
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
} else if (activeTab === 'general') {
loadRunningApps();
}
}, [activeTab]);
const loadRunningApps = async () => {
try {
const apps = await invoke<{ name: string, bundle_id: string }[]>('get_running_apps');
setRunningApps(apps);
} catch (e) {
console.error("Failed to load running apps:", e);
}
};
const loadLogs = async () => {
try {
const content = await invoke<string>('read_log_file');
@@ -143,7 +182,8 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
localAzure,
localEmailTemplates,
localDailyBackupEnabled,
localDailyBackupPath
localDailyBackupPath,
localBlockedApps
);
onClose();
};
@@ -229,6 +269,7 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
if (data.dailyBackup.enabled !== undefined) setLocalDailyBackupEnabled(data.dailyBackup.enabled);
if (data.dailyBackup.path) setLocalDailyBackupPath(data.dailyBackup.path);
}
if (data.blockedApps) setLocalBlockedApps(data.blockedApps);
// Import history!
if (data.history && Array.isArray(data.history)) {
props.onHistoryUpdate(data.history);
@@ -263,6 +304,7 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
{ 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: 'apps', label: 'Apps', icon: <Headphones size={14} /> },
{ id: 'logs', label: 'Logs', icon: <ScrollText size={14} /> },
] as const;
@@ -676,6 +718,86 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
</div>
)}
{activeTab === 'apps' && (
<div className="space-y-6 max-w-2xl h-full flex flex-col overflow-hidden">
<div className="space-y-4 flex-none">
<h3 className="text-foreground font-semibold border-b border-border pb-2 flex items-center gap-2">
<Lock size={16} /> Recording Filters
</h3>
<p className="text-xs text-muted-foreground">
Block specific apps to keep their audio out of your recordings (e.g., music or browser sounds).
</p>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar">
{/* App Presets */}
<div className="space-y-4">
<h4 className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Quick Select (Classic Apps)</h4>
<div className="grid grid-cols-2 gap-4">
{APP_PRESETS.map(group => (
<div key={group.category} className="space-y-2">
<h5 className="text-[11px] font-medium text-foreground/80">{group.category}</h5>
<div className="flex flex-wrap gap-1.5">
{group.apps.map(app => (
<button
key={app.id}
onClick={() => {
if (localBlockedApps.includes(app.id)) {
setLocalBlockedApps(localBlockedApps.filter(id => id !== app.id));
} else {
setLocalBlockedApps([...localBlockedApps, app.id]);
}
}}
className={`text-[10px] px-2 py-1 rounded border transition-all active:scale-95 ${localBlockedApps.includes(app.id)
? 'bg-primary/20 border-primary text-primary font-medium'
: 'bg-secondary/50 border-border/50 text-muted-foreground hover:border-border hover:text-foreground'
}`}
>
{app.name}
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Running Apps */}
<div className="space-y-4">
<h4 className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Currently Running</h4>
<div className="grid gap-2">
{runningApps.length > 0 ? (
runningApps.map(app => (
<label key={app.bundle_id} className="flex items-center justify-between p-3 bg-secondary/10 rounded border border-border/50 hover:bg-secondary/20 transition-colors cursor-pointer capitalize">
<div className="flex flex-col">
<span className="font-medium text-sm">{app.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{app.bundle_id}</span>
</div>
<input
type="checkbox"
checked={localBlockedApps.includes(app.bundle_id)}
onChange={(e) => {
if (e.target.checked) {
setLocalBlockedApps([...localBlockedApps, app.bundle_id]);
} else {
setLocalBlockedApps(localBlockedApps.filter(id => id !== app.bundle_id));
}
}}
className="w-4 h-4 rounded border-border bg-secondary text-primary focus:ring-primary"
/>
</label>
))
) : (
<div className="text-center py-8 text-muted-foreground text-xs italic border border-dashed border-border rounded">
No running applications detected.
</div>
)}
</div>
</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">

View File

@@ -0,0 +1,716 @@
import React, { useState, useEffect } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Mail, FileText, ScrollText } from 'lucide-react';
import { save, open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import EmailTemplateEditor from './EmailTemplateEditor';
import logo from '../assets/logo.png';
import { PromptTemplate, EmailTemplate } from '../App';
interface SettingsProps {
apiKey: string;
productId: string;
savePath: string;
prompts: PromptTemplate[];
emailTemplates: EmailTemplate[];
smtpConfig: SmtpConfig;
azureConfig: AzureConfig;
dailyBackupEnabled: boolean;
dailyBackupPath: string;
lastBackupDate: string;
history: any[];
onSave: (
apiKey: string,
productId: string,
prompts: PromptTemplate[],
savePath: string,
smtp: SmtpConfig,
azure: AzureConfig,
emailTemplates: EmailTemplate[],
dailyBackupEnabled: boolean,
dailyBackupPath: string
) => void;
onHistoryUpdate: (history: any[]) => void;
onClose: () => void;
}
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 [localProductId, setLocalProductId] = useState(productId);
const [localSavePath, setLocalSavePath] = useState(savePath);
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 [localDailyBackupEnabled, setLocalDailyBackupEnabled] = useState(props.dailyBackupEnabled);
const [localDailyBackupPath, setLocalDailyBackupPath] = useState(props.dailyBackupPath);
const [statusIdx, setStatusIdx] = 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) => {
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
};
const addPrompt = () => {
setLocalPrompts([...localPrompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
};
const removePrompt = (id: string) => {
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 = () => {
onSave(
localApiKey,
localProductId,
localPrompts,
localSavePath,
localSmtp,
localAzure,
localEmailTemplates,
localDailyBackupEnabled,
localDailyBackupPath
);
onClose();
};
const handleSelectFolder = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: localSavePath || undefined,
});
if (selected && typeof selected === 'string') {
setLocalSavePath(selected);
}
} catch (e) {
console.error("Failed to open directory picker", e);
setStatusIdx('Error: Failed to open directory picker.');
}
};
const handleExport = async () => {
try {
const data = {
apiKey: localApiKey,
productId: localProductId,
prompts: localPrompts,
savePath: localSavePath,
smtp: localSmtp,
azure: localAzure,
emailTemplates: localEmailTemplates,
history: props.history,
dailyBackup: {
enabled: localDailyBackupEnabled,
path: localDailyBackupPath,
}
};
// Always save as JSON (no encryption)
const content = JSON.stringify(data, null, 2);
const filePath = await save({
defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.json`,
filters: [{
name: 'Hearbit Config',
extensions: ['json']
}]
});
if (filePath) {
await invoke('save_text_file', { path: filePath, content });
setStatusIdx(`Configuration exported to: ${filePath}`);
}
} catch (e) {
console.error(e);
setStatusIdx(`Export failed: ${e}`);
}
};
const triggerImport = () => {
document.getElementById('import-file-input')?.click();
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
const content = event.target.result as string;
// Directly import without password modal since we don't use encryption
try {
const data = JSON.parse(content);
if (data.apiKey) setLocalApiKey(data.apiKey);
if (data.productId) setLocalProductId(data.productId);
if (data.prompts) setLocalPrompts(data.prompts);
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
if (data.savePath) setLocalSavePath(data.savePath);
if (data.smtp) setLocalSmtp(data.smtp);
if (data.azure) setLocalAzure(data.azure);
if (data.dailyBackup) {
if (data.dailyBackup.enabled !== undefined) setLocalDailyBackupEnabled(data.dailyBackup.enabled);
if (data.dailyBackup.path) setLocalDailyBackupPath(data.dailyBackup.path);
}
// Import history!
if (data.history && Array.isArray(data.history)) {
props.onHistoryUpdate(data.history);
}
setStatusIdx('Configuration imported! Click Save to apply.');
} catch (error) {
console.error(error);
setStatusIdx(`Import failed: ${error}`);
}
}
};
reader.readAsText(file);
e.target.value = '';
};
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 (
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
{/* Import Password Modal */}
{/* Email Template Editor Modal */}
<EmailTemplateEditor
isOpen={isEmailEditorOpen}
onClose={() => setIsEmailEditorOpen(false)}
template={editingTemplate}
onSave={handleSaveEmailTemplate}
smtpConfig={localSmtp}
addToast={(msg, type) => setStatusIdx(`${type === 'error' ? 'Error' : 'Success'}: ${msg}`)}
/>
<div className="flex flex-col border-b border-border/40 bg-secondary/10">
<div className="p-4 flex justify-between items-center">
<h2 className="text-lg font-semibold tracking-tight">Settings</h2>
<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">
<Save size={16} /> Save Changes
</button>
</div>
{/* Tabs */}
<div className="flex px-4 gap-2">
{tabs.map(tab => (
<button
key={tab.id}
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'}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'general' && (
<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">📸 Branding</h3>
<div className="p-4 bg-secondary/20 rounded border border-border/50">
<div className="mb-3">
<div className="font-medium text-sm mb-2">Custom Logo</div>
<div className="text-xs text-muted-foreground mb-3">Upload your company logo to replace the default Livtec branding throughout the app.</div>
</div>
{/* Logo Preview */}
<div className="flex items-center gap-4 mb-3">
<div className="w-20 h-20 bg-background border border-border rounded flex items-center justify-center overflow-hidden">
<img
src={localStorage.getItem('customLogo') || logo}
alt="Logo Preview"
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="flex-1">
<button
onClick={async () => {
try {
const selected = await open({
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg'] }]
});
if (selected && typeof selected === 'string') {
const dataUrl = await invoke<string>('read_image_as_base64', { filePath: selected });
localStorage.setItem('customLogo', dataUrl);
setStatusIdx('Logo uploaded! Save settings to apply.');
// Force re-render
window.dispatchEvent(new Event('storage'));
}
} catch (e) {
setStatusIdx(`Logo upload failed: ${e}`);
}
}}
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all flex items-center gap-2"
>
<Upload size={14} /> Upload Logo
</button>
<button
onClick={() => {
localStorage.removeItem('customLogo');
setStatusIdx('Logo reset to default. Save to apply.');
window.dispatchEvent(new Event('storage'));
}}
className="mt-2 bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all text-muted-foreground"
>
Reset to Default
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground">Supported: PNG, JPG, SVG. Recommended: Square format, transparent background.</p>
</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"
>
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">
{/* Manual Configuration Backup */}
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Manual Configuration Backup</h3>
<p className="text-xs text-muted-foreground">
Export all your settings, including API keys, prompts, email templates, and history as JSON files.
</p>
<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=".json"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
{/* Daily Automated Backup */}
<div className="space-y-4 border-t border-border pt-6">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Daily Automated Backup</h3>
<p className="text-xs text-muted-foreground">
Automatically backup your configuration once per day to prevent data loss. Backups are saved as JSON files.
</p>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enable-daily-backup"
checked={localDailyBackupEnabled}
onChange={(e) => setLocalDailyBackupEnabled(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enable-daily-backup" className="text-sm text-foreground cursor-pointer">
Enable daily automated backup
</label>
</div>
{localDailyBackupEnabled && (
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Backup Location
</label>
<div className="flex gap-2">
<input
type="text"
value={localDailyBackupPath}
onChange={(e) => setLocalDailyBackupPath(e.target.value)}
placeholder="Leave empty to use recordings folder"
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
<button
onClick={async () => {
try {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === 'string') {
setLocalDailyBackupPath(selected);
}
} catch (e) {
console.error(e);
}
}}
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"
>
<FolderOpen size={16} />
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
Last backup: {props.lastBackupDate || 'Never'}
</p>
</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>
{
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 >
);
};
export default Settings;