feat: improve audio exclusion with smart matching and reliability fixes
This commit is contained in:
12
src/App.tsx
12
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
716
src/components/Settings.tsx.bak
Normal file
716
src/components/Settings.tsx.bak
Normal 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;
|
||||
Reference in New Issue
Block a user