Release 1.1: Pre-meeting models, Calendar improvements, Logs, Compact Recorder
This commit is contained in:
140
src/App.tsx
140
src/App.tsx
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useState } from '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 LogViewer, { LogEntry } from "./components/LogViewer";
|
||||
|
||||
import TranscriptionView from "./components/TranscriptionView";
|
||||
import Tabs from "./components/Tabs";
|
||||
import MeetingsView from "./components/MeetingsView";
|
||||
@@ -17,9 +16,16 @@ export interface PromptTemplate {
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
const [view, setView] = useState<'recorder' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
const [lastTab, setLastTab] = useState<'recorder' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
|
||||
|
||||
// Auto-start recording state to handle "Join & Record" transition
|
||||
@@ -40,6 +46,23 @@ function App() {
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
||||
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
||||
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
|
||||
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
|
||||
@@ -164,20 +187,70 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
}
|
||||
];
|
||||
|
||||
// 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!`
|
||||
}
|
||||
];
|
||||
|
||||
const [prompts, setPrompts] = useState<PromptTemplate[]>(() => {
|
||||
const saved = localStorage.getItem('infomaniak_prompts');
|
||||
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);
|
||||
setProductId(newProductId);
|
||||
setPrompts(newPrompts);
|
||||
setSavePath(newSavePath);
|
||||
setSmtpConfig(newSmtp);
|
||||
setAzureConfig(newAzure);
|
||||
setEmailTemplates(newEmailTemplates);
|
||||
|
||||
localStorage.setItem('infomaniak_api_key', newApiKey);
|
||||
localStorage.setItem('infomaniak_product_id', newProductId);
|
||||
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -250,18 +323,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
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 (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
|
||||
@@ -271,7 +333,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
<div className="absolute right-4 top-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLastTab(view === 'logs' || view === 'history' ? view : 'recorder');
|
||||
setLastTab(view === 'history' ? view : 'recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
|
||||
@@ -281,7 +343,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
currentTab={view as 'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'}
|
||||
currentTab={view as 'recorder' | 'transcription' | 'meetings' | 'history'}
|
||||
onTabChange={(t) => setView(t)}
|
||||
/>
|
||||
</div>
|
||||
@@ -313,11 +375,34 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
recordingSubject={recordingSubject}
|
||||
onAutoStartHandled={() => setAutoStartRecording(false)}
|
||||
addToast={addToast}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'transcription' && (
|
||||
<TranscriptionView transcription={transcription} summary={summary} />
|
||||
<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 === 'history' && (
|
||||
@@ -329,18 +414,22 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
)}
|
||||
|
||||
{view === 'meetings' && (
|
||||
|
||||
<MeetingsView
|
||||
azureClientId={azureConfig.clientId}
|
||||
onStartRecording={(subject) => {
|
||||
setView('recorder');
|
||||
setRecordingSubject(subject || '');
|
||||
setAutoStartRecording(true);
|
||||
}}
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'logs' && (
|
||||
<LogViewer logs={logs} />
|
||||
)}
|
||||
|
||||
|
||||
{view === 'settings' && (
|
||||
<Settings
|
||||
@@ -350,6 +439,9 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
savePath={savePath}
|
||||
smtpConfig={smtpConfig}
|
||||
azureConfig={azureConfig}
|
||||
emailTemplates={emailTemplates}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
@@ -17,17 +17,28 @@ interface CalendarEvent {
|
||||
|
||||
interface MeetingsViewProps {
|
||||
onStartRecording: (subject?: string) => void;
|
||||
azureClientId: string;
|
||||
apiKey: string;
|
||||
productId: string;
|
||||
selectedModel: string;
|
||||
onModelChange: (model: string) => void;
|
||||
}
|
||||
|
||||
export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
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(localStorage.getItem('m365_client_id') || '');
|
||||
// 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)) {
|
||||
@@ -38,6 +49,24 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
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);
|
||||
@@ -46,16 +75,16 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
}, [token]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!clientId) {
|
||||
setError("Please enter a Client ID");
|
||||
if (!azureClientId) {
|
||||
setError("Please configure Client ID in Settings");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('m365_client_id', clientId);
|
||||
localStorage.setItem('m365_client_id', azureClientId);
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const accessToken = await invoke<string>('start_auth_flow', { clientId });
|
||||
const accessToken = await invoke<string>('start_auth_flow', { clientId: azureClientId });
|
||||
setToken(accessToken);
|
||||
localStorage.setItem('m365_token', accessToken);
|
||||
setIsAuthenticated(true);
|
||||
@@ -115,10 +144,24 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
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";
|
||||
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
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">
|
||||
@@ -137,17 +180,17 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Client ID (Dynamics/Azure)"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
className="text-sm p-2 rounded border border-input bg-background w-full"
|
||||
/>
|
||||
<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 || !clientId}
|
||||
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} />}
|
||||
@@ -171,7 +214,21 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
<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 gap-2">
|
||||
<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>
|
||||
@@ -196,88 +253,93 @@ export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3">
|
||||
{events.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-sm font-bold text-primary bg-primary/10 px-2 py-0.5 rounded">
|
||||
{formatDate(event.start.dateTime)}
|
||||
</span>
|
||||
<span className="text-lg font-mono font-medium">
|
||||
{formatTime(event.start.dateTime)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
||||
{event.subject}
|
||||
</h3>
|
||||
{event.location?.displayName && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
📍 {event.location.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.onlineMeeting?.joinUrl ? (
|
||||
<button
|
||||
onClick={() => handleJoin(event.onlineMeeting?.joinUrl, event.subject)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-sm hover:shadow transition-all"
|
||||
>
|
||||
<Video size={18} />
|
||||
<span className="font-semibold">Join & Record</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-1.5 bg-secondary text-muted-foreground text-xs rounded-lg italic">
|
||||
No online link
|
||||
</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>
|
||||
|
||||
{/* Expand/Collapse Button */}
|
||||
<button
|
||||
onClick={() => toggleExpand(event.id)}
|
||||
className="text-xs text-muted-foreground hover:text-primary mt-2 flex items-center gap-1 transition-colors w-full justify-center py-1 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-3 text-sm 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-sm 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-4 pt-4 border-t border-border/50">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
👥 Attendees
|
||||
<span className="text-xs font-normal text-muted-foreground bg-secondary px-1.5 py-0.5 rounded-full">
|
||||
{event.attendees.length}
|
||||
{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>
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.attendees.map((att, i) => (
|
||||
<div key={i} className="flex items-center gap-2 bg-secondary/50 border border-border/50 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-secondary hover:border-border">
|
||||
<div className={`w-2 h-2 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="font-medium truncate max-w-[200px]" title={att.emailAddress.address}>
|
||||
{att.emailAddress.name || att.emailAddress.address}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface RecorderProps {
|
||||
recordingSubject?: string;
|
||||
onAutoStartHandled?: () => void;
|
||||
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
|
||||
selectedModel: string;
|
||||
onModelChange: (model: string) => void;
|
||||
}
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -51,14 +53,15 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
apiKey, productId, prompts,
|
||||
setTranscription, setSummary,
|
||||
onSaveToHistory, savePath, onRecordingComplete,
|
||||
onOpenSettings, addToast, ...props
|
||||
onOpenSettings, addToast, selectedModel, onModelChange, ...props
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false); // New lock state
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [status, setStatus] = useState<string>('Ready to record');
|
||||
const [selectedDevice, setSelectedDevice] = 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 [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||
@@ -191,9 +194,9 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
setSilenceDuration(diff);
|
||||
|
||||
// Auto-stop after 30 seconds of silence
|
||||
if (diff > 30) { // 30 seconds
|
||||
if (diff > 30 && !isStopping) { // Check lock
|
||||
console.log("Auto-stopping due to silence");
|
||||
setStatus("Auto-stopping (Silence detected)...");
|
||||
addToast("Auto-stopping (Silence detected)", "info", 3000);
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
@@ -264,12 +267,18 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (isStopping) return;
|
||||
setIsStopping(true);
|
||||
|
||||
try {
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
setStatus('Processing...');
|
||||
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)...');
|
||||
const transText = await invoke<string>('transcribe_audio', {
|
||||
filePath,
|
||||
@@ -343,51 +352,53 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
console.error(e);
|
||||
setStatus(`Error: ${e}`);
|
||||
addToast(`Error processing: ${e}`, 'error');
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-background relative">
|
||||
{/* Fixed Header */}
|
||||
<div className="w-full flex justify-center items-center p-6 shrink-0">
|
||||
<img src={logo} alt="Logo" className="h-12 object-contain" />
|
||||
{/* Fixed Header - Reduced padding */}
|
||||
<div className="w-full flex justify-center items-center p-4 shrink-0">
|
||||
<img src={logo} alt="Logo" className="h-10 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
||||
<div className="mb-6 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'}`}>
|
||||
{/* Scrollable Content - Reduced spacing */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
|
||||
<div className="mb-4 relative shrink-0">
|
||||
<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 ? (
|
||||
<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'}`}>
|
||||
<Mic size={40} className="text-white animate-bounce" />
|
||||
<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={32} className="text-white animate-bounce" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={40} className="text-primary-foreground" />
|
||||
<div className="w-16 h-16 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={32} className="text-primary-foreground" />
|
||||
</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'}
|
||||
</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}
|
||||
{isRecording && !isPaused && silenceDuration > 10 && (
|
||||
<span className="block text-xs text-yellow-500 mt-1 opacity-80">
|
||||
Silence detected: {Math.floor(silenceDuration)}s (Auto-stop in {90 - Math.floor(silenceDuration)}s)
|
||||
<span className="block text-xs text-yellow-500 mt-0.5 opacity-80">
|
||||
Silence detected: {Math.floor(silenceDuration)}s
|
||||
</span>
|
||||
)}
|
||||
</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 ? (
|
||||
<button
|
||||
onClick={() => startRecording()}
|
||||
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'}
|
||||
</button>
|
||||
@@ -456,9 +467,13 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
</label>
|
||||
<select
|
||||
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"
|
||||
disabled={isRecording}
|
||||
// Allow changing model while recording (since it's used for summary after)
|
||||
disabled={false}
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
@@ -475,7 +490,8 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
value={selectedPromptId}
|
||||
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"
|
||||
disabled={isRecording || prompts.length === 0}
|
||||
// Allow changing template while recording
|
||||
disabled={prompts.length === 0}
|
||||
>
|
||||
{prompts.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff, Mail, FileText, ScrollText } from 'lucide-react';
|
||||
import { save, open } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
// Removed writeTextFile as we use invoke 'save_text_file'
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { encryptData, decryptData } from '../utils/backup';
|
||||
import { PromptTemplate } from '../App';
|
||||
import EmailTemplateEditor from './EmailTemplateEditor';
|
||||
|
||||
import { PromptTemplate, EmailTemplate } from '../App';
|
||||
|
||||
interface SettingsProps {
|
||||
apiKey: string;
|
||||
productId: string;
|
||||
savePath: string;
|
||||
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;
|
||||
}
|
||||
|
||||
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 [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 [statusIdx, setStatusIdx] = useState<string | null>(null);
|
||||
|
||||
// Backup & Restore State
|
||||
@@ -28,6 +59,48 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
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) => {
|
||||
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
||||
};
|
||||
@@ -40,8 +113,26 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
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);
|
||||
onSave(localApiKey, localProductId, localPrompts, localSavePath, localSmtp, localAzure, localEmailTemplates);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -71,7 +162,9 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
apiKey: localApiKey,
|
||||
productId: localProductId,
|
||||
prompts: localPrompts,
|
||||
savePath: localSavePath
|
||||
savePath: localSavePath,
|
||||
smtp: localSmtp,
|
||||
azure: localAzure
|
||||
};
|
||||
const encrypted = await encryptData(data, backupPassword);
|
||||
|
||||
@@ -84,12 +177,13 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeTextFile(filePath, encrypted);
|
||||
// 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) {
|
||||
console.error(e);
|
||||
setStatusIdx('Export failed.');
|
||||
setStatusIdx(`Export failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,8 +218,12 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
const data = await decryptData(importFileContent, backupPassword);
|
||||
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);
|
||||
|
||||
setStatusIdx('Configuration imported! Click Save to apply.');
|
||||
setIsImportModalOpen(false);
|
||||
@@ -147,6 +245,14 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
}
|
||||
};
|
||||
|
||||
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 */}
|
||||
@@ -193,156 +299,354 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-b border-border/40 bg-secondary/20 flex justify-between items-center">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Settings</span>
|
||||
<button onClick={handleSave} className="flex items-center gap-1 text-primary hover:text-primary/80 transition-all font-semibold active:scale-95">
|
||||
<Save size={16} /> Save
|
||||
</button>
|
||||
</div>
|
||||
{/* 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-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
<h3 className="text-foreground font-semibold flex items-center gap-2">General</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>
|
||||
<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 className="pt-2 border-t border-border/50 mt-2">
|
||||
<label className="block text-sm font-medium mb-1 text-foreground">Advanced Audio Setup</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
For automatic recording in Teams, create a virtual device combining your Mic and computer audio.
|
||||
</p>
|
||||
<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 "Hearbit Audio" Device
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
<h3 className="text-foreground font-semibold flex items-center gap-2">
|
||||
<Lock size={16} /> Backup & Restore
|
||||
</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"
|
||||
/>
|
||||
{/* Tabs */}
|
||||
<div className="flex px-4 gap-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
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'}`}
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
{tab.icon} {tab.label}
|
||||
</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 active:scale-95"
|
||||
>
|
||||
<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 active:scale-95"
|
||||
>
|
||||
<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 transition-all active:scale-95">
|
||||
+ 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>
|
||||
|
||||
{statusIdx && (
|
||||
<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'}`}>
|
||||
{statusIdx}
|
||||
<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">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>
|
||||
|
||||
{
|
||||
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,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Mic, Terminal, FileText, Calendar } from 'lucide-react';
|
||||
import { Mic, FileText, Calendar } from 'lucide-react';
|
||||
|
||||
interface TabsProps {
|
||||
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history';
|
||||
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history') => void;
|
||||
currentTab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history';
|
||||
onTabChange: (tab: 'recorder' | 'transcription' | 'settings' | 'meetings' | 'history') => void;
|
||||
}
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||
@@ -37,16 +37,7 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||
<FileText size={16} />
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('logs')}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${currentTab === 'logs'
|
||||
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
}`}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
Logs
|
||||
</button>
|
||||
|
||||
{/* 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 prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".
|
||||
|
||||
@@ -1,15 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
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 {
|
||||
transcription: 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 [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) => {
|
||||
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="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>
|
||||
<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 className="flex items-center gap-2">
|
||||
{/* Regenerate Trigger */}
|
||||
<button
|
||||
onClick={() => setShowRegenOptions(!showRegenOptions)}
|
||||
disabled={!transcription || !apiKey}
|
||||
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'}`}
|
||||
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>
|
||||
{/* 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">
|
||||
{summary ? (
|
||||
<ReactMarkdown>{summary}</ReactMarkdown>
|
||||
@@ -74,7 +202,18 @@ const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, su
|
||||
</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 >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user