feat: complete history, attendees list, and smart templates
This commit is contained in:
169
src/App.tsx
169
src/App.tsx
@@ -6,18 +6,37 @@ 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";
|
||||
import HistoryView from "./components/HistoryView";
|
||||
import ToastContainer, { ToastMessage, ToastType } from "./components/ui/Toast";
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription'>('recorder');
|
||||
// Keep track of the *previous* tab to return to from settings
|
||||
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription'>('recorder');
|
||||
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'>('recorder');
|
||||
|
||||
|
||||
// Auto-start recording state to handle "Join & Record" transition
|
||||
const [autoStartRecording, setAutoStartRecording] = useState(false);
|
||||
const [recordingSubject, setRecordingSubject] = useState<string>('');
|
||||
|
||||
// Toast State
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const addToast = (message: string, type: ToastType = 'info', duration = 3000) => {
|
||||
const id = Date.now().toString() + Math.random().toString();
|
||||
setToasts(prev => [...prev, { id, message, type, duration }]);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
};
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
||||
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
||||
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
|
||||
@@ -61,7 +80,8 @@ Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklärt wurd
|
||||
| [Aufgabe 2] | [Name] | [Datum] |
|
||||
|
||||
## 5. Nächste Schritte / Nächstes Meeting
|
||||
Kurze Info zum weiteren Vorgehen.`
|
||||
Kurze Info zum weiteren Vorgehen.`,
|
||||
keywords: ['protokoll', 'meeting', 'team', 'daily', 'weekly']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -96,7 +116,8 @@ Thema B: [Kurze Zusammenfassung]
|
||||
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
|
||||
| :--- | :--- | :--- |
|
||||
| [Name] | [Aufgabe] | [Datum] |
|
||||
| [Name] | [Aufgabe] | [Datum] |`
|
||||
| [Name] | [Aufgabe] | [Datum] |`,
|
||||
keywords: ['personal', 'privat', 'vertraulich', 'entwicklungsgespräch', 'feedback', 'unter vier augen']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -138,7 +159,8 @@ Teilnehmer: [Namen Kunden] & [Namen Intern]
|
||||
[ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum])
|
||||
|
||||
## 5. Nächster Termin / Timeline
|
||||
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`,
|
||||
keywords: ['beratung', 'kunde', 'client', 'angebot', 'projekt', 'extern']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -168,6 +190,8 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
date: string;
|
||||
transcription: string;
|
||||
summary: string;
|
||||
subject?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const [history, setHistory] = useState<HistoryItem[]>(() => {
|
||||
@@ -179,16 +203,39 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
const transToSave = t !== undefined ? t : transcription;
|
||||
const sumToSave = s !== undefined ? s : summary;
|
||||
|
||||
// Sanitize subject for filename
|
||||
const safeSubject = recordingSubject
|
||||
? recordingSubject.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
: `Meeting_${Date.now()}`;
|
||||
const filename = `${safeSubject}.md`;
|
||||
|
||||
if (!transToSave && !sumToSave) return;
|
||||
|
||||
const newItem: HistoryItem = {
|
||||
id: Date.now().toString(),
|
||||
date: new Date().toLocaleString(),
|
||||
transcription: transToSave,
|
||||
summary: sumToSave
|
||||
summary: sumToSave,
|
||||
subject: recordingSubject || "Untitled Recording",
|
||||
filename: filename
|
||||
};
|
||||
const newHistory = [newItem, ...history];
|
||||
setHistory(newHistory);
|
||||
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
|
||||
|
||||
// Persist to Disk (Markdown)
|
||||
const content = `# ${newItem.subject}\nDate: ${newItem.date}\n\n## Summary\n${sumToSave}\n\n## Transcription\n${transToSave}`;
|
||||
// If savePath is set, we use it. If not, backend defaults to temp. Here we want to save text.
|
||||
// Let's assume savePath is set or we default to Documents/Hearbit (if we could).
|
||||
// For now, if savePath is set, use it.
|
||||
if (savePath) {
|
||||
// We need invoke to save text
|
||||
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||
invoke('save_text_file', { path: `${savePath}/${filename}`, content })
|
||||
.then(() => addToast('Transcript saved to file', 'success'))
|
||||
.catch(e => addToast(`Failed to save file: ${e}`, 'error'));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteHistory = (id: string) => {
|
||||
@@ -200,7 +247,7 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
const handleLoadHistory = (item: HistoryItem) => {
|
||||
setTranscription(item.transcription);
|
||||
setSummary(item.summary);
|
||||
setView('recorder'); // Ensure we go back to recorder to see it
|
||||
setView('transcription'); // Switch to Transcription view to see content
|
||||
};
|
||||
|
||||
// Logs State
|
||||
@@ -224,7 +271,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' ? 'logs' : 'recorder');
|
||||
setLastTab(view === 'logs' || view === 'history' ? view : 'recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
|
||||
@@ -234,53 +281,79 @@ Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
currentTab={view as 'recorder' | 'logs' | 'transcription'}
|
||||
currentTab={view as 'recorder' | 'logs' | 'transcription' | 'meetings' | 'history'}
|
||||
onTabChange={(t) => setView(t)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{view === 'recorder' && (
|
||||
<Recorder
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
onOpenSettings={() => {
|
||||
setLastTab('recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
transcription={transcription}
|
||||
setTranscription={setTranscription}
|
||||
summary={summary}
|
||||
setSummary={setSummary}
|
||||
history={history}
|
||||
onSaveToHistory={handleSaveToHistory}
|
||||
onDeleteHistory={handleDeleteHistory}
|
||||
onLoadHistory={handleLoadHistory}
|
||||
savePath={savePath}
|
||||
onRecordingComplete={() => setView('transcription')}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex h-full overflow-hidden relative">
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{view === 'recorder' && (
|
||||
<Recorder
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
onOpenSettings={() => {
|
||||
setLastTab('recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
transcription={transcription}
|
||||
setTranscription={setTranscription}
|
||||
summary={summary}
|
||||
setSummary={setSummary}
|
||||
history={history}
|
||||
onSaveToHistory={handleSaveToHistory}
|
||||
onDeleteHistory={handleDeleteHistory}
|
||||
onLoadHistory={handleLoadHistory}
|
||||
savePath={savePath}
|
||||
|
||||
{view === 'transcription' && (
|
||||
<TranscriptionView transcription={transcription} summary={summary} />
|
||||
)}
|
||||
onRecordingComplete={() => setView('transcription')}
|
||||
autoStart={autoStartRecording}
|
||||
recordingSubject={recordingSubject}
|
||||
onAutoStartHandled={() => setAutoStartRecording(false)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'logs' && (
|
||||
<LogViewer logs={logs} />
|
||||
)}
|
||||
{view === 'transcription' && (
|
||||
<TranscriptionView transcription={transcription} summary={summary} />
|
||||
)}
|
||||
|
||||
{view === 'settings' && (
|
||||
<Settings
|
||||
onSave={handleSaveSettings}
|
||||
onClose={() => setView(lastTab)}
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
savePath={savePath}
|
||||
/>
|
||||
)}
|
||||
{view === 'history' && (
|
||||
<HistoryView
|
||||
history={history}
|
||||
onLoad={handleLoadHistory}
|
||||
onDelete={handleDeleteHistory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'meetings' && (
|
||||
<MeetingsView
|
||||
onStartRecording={(subject) => {
|
||||
setView('recorder');
|
||||
setRecordingSubject(subject || '');
|
||||
setAutoStartRecording(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'logs' && (
|
||||
<LogViewer logs={logs} />
|
||||
)}
|
||||
|
||||
{view === 'settings' && (
|
||||
<Settings
|
||||
onSave={handleSaveSettings}
|
||||
onClose={() => setView(lastTab)}
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
savePath={savePath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
67
src/components/HistoryView.tsx
Normal file
67
src/components/HistoryView.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FileText, Trash2, Calendar } from 'lucide-react';
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
date: string;
|
||||
transcription: string; // This might be raw text or path?
|
||||
summary: string;
|
||||
subject?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface HistoryViewProps {
|
||||
history: HistoryItem[];
|
||||
onLoad: (item: HistoryItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function HistoryView({ history, onLoad, onDelete }: HistoryViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-background p-6">
|
||||
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<FileText className="w-8 h-8" />
|
||||
Recording History
|
||||
</h1>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<FileText size={48} className="mb-4 opacity-20" />
|
||||
<p>No history found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{history.map(item => (
|
||||
<div key={item.id} className="bg-card border border-border rounded-xl p-4 hover:shadow-md transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => onLoad(item)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-1">
|
||||
{item.subject || "Untitled Recording"}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Calendar size={12} />
|
||||
{item.date}
|
||||
{item.filename && <span className="bg-secondary px-1.5 py-0.5 rounded text-[10px] font-mono">{item.filename}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-foreground/70 line-clamp-2">
|
||||
{item.summary ? item.summary.substring(0, 150) + "..." : "No summary available."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
|
||||
className="text-muted-foreground hover:text-destructive p-2 rounded-lg hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
src/components/MeetingsView.tsx
Normal file
289
src/components/MeetingsView.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Calendar, RefreshCw, LogIn, Video } from 'lucide-react';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
subject: string;
|
||||
start: { dateTime: string, timeZone: string };
|
||||
end: { dateTime: string, timeZone: string };
|
||||
onlineMeeting?: { joinUrl: string };
|
||||
location?: { displayName: string };
|
||||
bodyPreview?: string; // Text preview
|
||||
body?: { content: string, contentType: string }; // Full HTML/Text
|
||||
attendees?: { emailAddress: { name: string, address: string }, type: string, status: { response: string } }[];
|
||||
}
|
||||
|
||||
interface MeetingsViewProps {
|
||||
onStartRecording: (subject?: string) => void;
|
||||
}
|
||||
|
||||
export default function MeetingsView({ onStartRecording }: MeetingsViewProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [token, setToken] = useState(localStorage.getItem('m365_token') || '');
|
||||
const [clientId, setClientId] = useState(localStorage.getItem('m365_client_id') || '');
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
setIsAuthenticated(true);
|
||||
fetchEvents(token);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!clientId) {
|
||||
setError("Please enter a Client ID");
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('m365_client_id', clientId);
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const accessToken = await invoke<string>('start_auth_flow', { clientId });
|
||||
setToken(accessToken);
|
||||
localStorage.setItem('m365_token', accessToken);
|
||||
setIsAuthenticated(true);
|
||||
fetchEvents(accessToken);
|
||||
} catch (err) {
|
||||
console.error("Auth failed", err);
|
||||
setError(String(err)); // Use String() to safely convert error object
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEvents = async (authToken: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await invoke<CalendarEvent[]>('get_calendar_events', { token: authToken });
|
||||
// Sort by start time
|
||||
const sorted = data.sort((a, b) => new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime());
|
||||
setEvents(sorted);
|
||||
} catch (err) {
|
||||
console.error("Fetch failed", err);
|
||||
setError(`Fetch failed: ${err}`);
|
||||
// If error is 401, logout
|
||||
if (String(err).includes('401')) {
|
||||
logout();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken('');
|
||||
localStorage.removeItem('m365_token');
|
||||
setIsAuthenticated(false);
|
||||
setEvents([]);
|
||||
};
|
||||
|
||||
const handleJoin = async (joinUrl?: string, subject?: string) => {
|
||||
if (!joinUrl) return;
|
||||
try {
|
||||
// 1. Open URL
|
||||
await openUrl(joinUrl);
|
||||
// 2. Start Recording (wait a sec for app focus switch?)
|
||||
// Actually user might want to confirm recording? Protocol says "one-click".
|
||||
onStartRecording(subject);
|
||||
} catch (e) {
|
||||
console.error("Failed to join", e);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (isoString: string) => {
|
||||
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatDate = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const today = new Date();
|
||||
if (date.toDateString() === today.toDateString()) return "Today";
|
||||
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-background p-6">
|
||||
<h1 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<Calendar className="w-8 h-8" />
|
||||
Upcoming Meetings
|
||||
</h1>
|
||||
|
||||
{/* Auth Section */}
|
||||
{!isAuthenticated ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-6 text-center max-w-md mx-auto">
|
||||
<div className="bg-secondary/30 p-8 rounded-xl border border-border">
|
||||
<Calendar className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Connect Microsoft 365</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Connect your account to see upcoming Teams & Zoom meetings and join them with one click.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<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"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !clientId}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 w-full transition-all"
|
||||
>
|
||||
{loading ? <RefreshCw className="animate-spin" size={16} /> : <LogIn size={16} />}
|
||||
Connect Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-destructive/10 text-destructive text-xs rounded-md text-left break-all">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mt-4 px-2">
|
||||
Note: Requires an Azure App Registration (Multitenant) with redirect URI: <br />
|
||||
<code className="bg-secondary px-1 rounded">http://localhost:14200/auth/callback</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex justify-between items-center mb-4 px-1">
|
||||
<span className="text-sm text-muted-foreground font-medium">Next 7 Days</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fetchEvents(token)} disabled={loading} className="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-secondary transition-colors" title="Refresh">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button onClick={logout} className="text-muted-foreground hover:text-destructive p-1 rounded hover:bg-destructive/10 transition-colors" title="Logout">
|
||||
<LogIn size={16} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length === 0 && !loading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
{/* No meetings empty state (only if no error) */}
|
||||
<Calendar size={48} className="mb-4 opacity-20" />
|
||||
<p>No upcoming meetings found for the next 7 days.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="m-4 p-3 bg-destructive/10 text-destructive text-sm rounded-md flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => fetchEvents(token)} className="underline hover:no-underline ml-2">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-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>
|
||||
|
||||
{/* 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}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mic, Square } from 'lucide-react';
|
||||
import { Mic, Square, Users, Headphones } from 'lucide-react';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import logo from '../assets/logo.png'; // Import logo
|
||||
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
@@ -32,7 +34,12 @@ interface RecorderProps {
|
||||
onDeleteHistory: (id: string) => void;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
savePath: string | null;
|
||||
|
||||
onRecordingComplete: () => void;
|
||||
autoStart?: boolean;
|
||||
recordingSubject?: string;
|
||||
onAutoStartHandled?: () => void;
|
||||
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
|
||||
}
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -43,7 +50,8 @@ interface AudioDevice {
|
||||
const Recorder: React.FC<RecorderProps> = ({
|
||||
apiKey, productId, prompts,
|
||||
setTranscription, setSummary,
|
||||
onSaveToHistory, savePath, onRecordingComplete
|
||||
onSaveToHistory, savePath, onRecordingComplete,
|
||||
onOpenSettings, addToast, ...props
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -51,8 +59,17 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
||||
const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice');
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||
const [lastSpeechTime, setLastSpeechTime] = useState<number>(Date.now());
|
||||
const [silenceDuration, setSilenceDuration] = useState(0);
|
||||
|
||||
// Filtered devices based on mode
|
||||
const filteredDevices = devices.filter(d => {
|
||||
const isVirtual = d.name.toLowerCase().includes('hearbit') || d.name.toLowerCase().includes('blackhole');
|
||||
return recordingMode === 'meeting' ? isVirtual : !isVirtual;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -95,12 +112,21 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
setDevices(aliasedDevs);
|
||||
|
||||
// Select Hearbit mic by default if available and no selection made
|
||||
// Smart Auto-select based on mode
|
||||
if (!selectedDevice) {
|
||||
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
|
||||
if (vb) {
|
||||
setSelectedDevice(vb.id);
|
||||
} else if (aliasedDevs.length > 0) {
|
||||
setSelectedDevice(aliasedDevs[0].id);
|
||||
// Prioritize "Hearbit Audio" (Aggregate) over "Hearbit Virtual Mic" (BlackHole)
|
||||
const aggregateDev = aliasedDevs.find(d => d.name === 'Hearbit Audio');
|
||||
const virtualDev = aliasedDevs.find(d => d.name.includes('Hearbit Virtual'));
|
||||
|
||||
if (aggregateDev) {
|
||||
setRecordingMode('meeting');
|
||||
setSelectedDevice(aggregateDev.id);
|
||||
} else if (virtualDev) {
|
||||
setRecordingMode('meeting');
|
||||
setSelectedDevice(virtualDev.id);
|
||||
} else {
|
||||
setRecordingMode('voice');
|
||||
if (aliasedDevs.length > 0) setSelectedDevice(aliasedDevs[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -113,26 +139,114 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
await invoke('open_audio_midi_setup');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addToast('Failed to open Audio Setup', 'error');
|
||||
setStatus('Failed to open Audio Setup');
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
const startRecording = async (deviceIdOverride?: string) => {
|
||||
try {
|
||||
setStatus('Starting...');
|
||||
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
||||
setStatus('Starting...');
|
||||
// Check override or state
|
||||
const targetDeviceId = deviceIdOverride || selectedDevice;
|
||||
|
||||
// Pass customFilename (camelCase key maps to snake_case in Rust automatically or we need to check Tauri mapping, usually it maps camel to camel? Rust expects snake. Let's use snake_case in invoke args to be safe)
|
||||
await invoke('start_recording', { deviceId: targetDeviceId, savePath: savePath || null, customFilename: props.recordingSubject || null });
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setTranscription('');
|
||||
setSummary('');
|
||||
setStatus('Recording...');
|
||||
addToast('Recording started', 'success', 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus(`Error: ${e}`);
|
||||
addToast(`Error starting recording: ${e}`, 'error');
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
// VAD & Auto-Stop Logic
|
||||
useEffect(() => {
|
||||
let unlisten: () => void;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ is_speech: boolean, probability: number }>('vad-event', (event) => {
|
||||
if (event.payload.is_speech) {
|
||||
setLastSpeechTime(Date.now());
|
||||
setSilenceDuration(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isRecording && !isPaused) {
|
||||
setupListener();
|
||||
setLastSpeechTime(Date.now()); // Reset on start
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isRecording && !isPaused) {
|
||||
const diff = (Date.now() - lastSpeechTime) / 1000;
|
||||
setSilenceDuration(diff);
|
||||
|
||||
// Auto-stop after 30 seconds of silence
|
||||
if (diff > 30) { // 30 seconds
|
||||
console.log("Auto-stopping due to silence");
|
||||
setStatus("Auto-stopping (Silence detected)...");
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isRecording, isPaused, lastSpeechTime]);
|
||||
|
||||
// Handle Auto Start Prop
|
||||
useEffect(() => {
|
||||
if (props.autoStart && !isRecording && devices.length > 0) {
|
||||
// Force meeting mode for auto-joins
|
||||
if (recordingMode !== 'meeting') {
|
||||
setRecordingMode('meeting');
|
||||
}
|
||||
|
||||
// Find best device (Race condition fix: we can't rely on selectedDevice state update being instant)
|
||||
const aggregateDev = devices.find(d => d.name === 'Hearbit Audio');
|
||||
const virtualDev = devices.find(d => d.name.includes('Hearbit Virtual'));
|
||||
const bestDevice = aggregateDev || virtualDev;
|
||||
|
||||
if (bestDevice) {
|
||||
setSelectedDevice(bestDevice.id); // Update UI state for consistency
|
||||
console.log("Auto-starting with device:", bestDevice.name);
|
||||
startRecording(bestDevice.id); // Pass ID directly
|
||||
} else {
|
||||
console.warn("Auto-start: No meeting device found, trying default.");
|
||||
startRecording();
|
||||
}
|
||||
|
||||
if (props.onAutoStartHandled) {
|
||||
props.onAutoStartHandled();
|
||||
}
|
||||
}
|
||||
}, [props.autoStart, devices]);
|
||||
|
||||
// Handle Custom Event (Legacy/Fallback)
|
||||
useEffect(() => {
|
||||
const handleStartReq = () => {
|
||||
if (!isRecording) {
|
||||
if (recordingMode !== 'meeting') {
|
||||
setRecordingMode('meeting');
|
||||
}
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
window.addEventListener('start-recording-req', handleStartReq);
|
||||
return () => window.removeEventListener('start-recording-req', handleStartReq);
|
||||
}, [isRecording, recordingMode]);
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
@@ -172,8 +286,40 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Find selected prompt content
|
||||
const activePrompt = prompts.find(p => p.id === selectedPromptId);
|
||||
// Find selected prompt content - SMART SELECTION
|
||||
let activePrompt = prompts.find(p => p.id === selectedPromptId);
|
||||
|
||||
// Smart Auto-Select based on keywords
|
||||
const lowerText = transText.toLowerCase();
|
||||
let bestMatchId = selectedPromptId;
|
||||
let maxMatches = 0;
|
||||
|
||||
for (const p of prompts) {
|
||||
if (!p.keywords) continue;
|
||||
let matches = 0;
|
||||
for (const kw of p.keywords) {
|
||||
if (lowerText.includes(kw.toLowerCase())) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
bestMatchId = p.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatchId !== selectedPromptId) {
|
||||
const newPrompt = prompts.find(p => p.id === bestMatchId);
|
||||
if (newPrompt) {
|
||||
console.log(`Smart Select: Switched to '${newPrompt.name}' with ${maxMatches} matches.`);
|
||||
setStatus(`Smart Select: Using "${newPrompt.name}"...`);
|
||||
addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'success', 4000);
|
||||
activePrompt = newPrompt;
|
||||
// Optional: Update UI selection? setSelectedPromptId(bestMatchId);
|
||||
// Let's verify with user preference? For now, we override as "Magic".
|
||||
}
|
||||
}
|
||||
|
||||
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
|
||||
|
||||
setStatus(`Summarizing (${selectedModel})...`);
|
||||
@@ -190,11 +336,13 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
onSaveToHistory(transText, sumText);
|
||||
|
||||
setStatus('Done!');
|
||||
addToast('Transcription & Summary complete!', 'success', 4000);
|
||||
onRecordingComplete(); // Auto-switch tab
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus(`Error: ${e}`);
|
||||
addToast(`Error processing: ${e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,12 +375,17 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{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>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
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"
|
||||
>
|
||||
@@ -260,22 +413,43 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
</div>
|
||||
|
||||
{/* INPUT DEVICE SECTION */}
|
||||
<div className="col-span-2">
|
||||
<div className="flex bg-secondary p-1 rounded-lg mb-2">
|
||||
<button
|
||||
onClick={() => { setRecordingMode('voice'); setSelectedDevice(''); }}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'voice' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
<Headphones size={14} /> Voice Memo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRecordingMode('meeting'); setSelectedDevice(''); }}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'meeting' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Users size={14} /> Meeting
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{filteredDevices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{filteredDevices.length === 0 && (
|
||||
<option value="">
|
||||
{recordingMode === 'meeting' ? 'No Meeting Device (Create in Settings)' : 'No Microphone Found'}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
@@ -311,11 +485,19 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||
{recordingMode === 'meeting' && filteredDevices.length === 0 && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 w-full text-center border border-primary/20 rounded p-2 mb-2 font-semibold"
|
||||
>
|
||||
🪄 Create "Hearbit Audio" Device
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={openAudioSetup}
|
||||
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
||||
>
|
||||
Open Audio MIDI Setup (Configure Multi-Output)
|
||||
Open Audio MIDI Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { save, open } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { encryptData, decryptData } from '../utils/backup';
|
||||
import { PromptTemplate } from '../App';
|
||||
|
||||
@@ -72,16 +74,19 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
savePath: localSavePath
|
||||
};
|
||||
const encrypted = await encryptData(data, backupPassword);
|
||||
const blob = new Blob([encrypted], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setStatusIdx('Configuration exported successfully!');
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`,
|
||||
filters: [{
|
||||
name: 'Hearbit Config',
|
||||
extensions: ['conf']
|
||||
}]
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeTextFile(filePath, encrypted);
|
||||
setStatusIdx(`Configuration exported to: ${filePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatusIdx('Export failed.');
|
||||
@@ -131,6 +136,17 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDevice = async () => {
|
||||
try {
|
||||
setStatusIdx('Creating Hearbit Audio device...');
|
||||
await invoke('create_hearbit_audio_device');
|
||||
setStatusIdx('Success! "Hearbit Audio" device created.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatusIdx(`Error: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
|
||||
{/* Import Password Modal */}
|
||||
@@ -179,7 +195,7 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
|
||||
<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-colors font-semibold">
|
||||
<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>
|
||||
@@ -220,13 +236,25 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
/>
|
||||
<button
|
||||
onClick={handleSelectFolder}
|
||||
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-colors"
|
||||
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>
|
||||
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
@@ -256,13 +284,13 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
<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"
|
||||
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"
|
||||
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>
|
||||
@@ -279,7 +307,7 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Mic, Terminal, FileText } from 'lucide-react';
|
||||
import { Mic, Terminal, FileText, Calendar } from 'lucide-react';
|
||||
|
||||
interface TabsProps {
|
||||
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings';
|
||||
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void;
|
||||
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history';
|
||||
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings' | 'meetings' | 'history') => void;
|
||||
}
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||
@@ -22,6 +23,20 @@ const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||
<FileText size={16} />
|
||||
Transcription
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('meetings')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'meetings' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
Meetings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('history')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'history' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
History
|
||||
</button>
|
||||
<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'
|
||||
|
||||
81
src/components/ui/Toast.tsx
Normal file
81
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastMessage;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Determine background color based on type
|
||||
// Animate in
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onClose(toast.id), 300); // Wait for animation
|
||||
}, toast.duration || 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast, onClose]);
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-destructive',
|
||||
info: 'bg-blue-600'
|
||||
};
|
||||
|
||||
const icon = {
|
||||
success: <CheckCircle size={18} />,
|
||||
error: <AlertCircle size={18} />,
|
||||
info: <Info size={18} />
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white mb-2 transition-all duration-300 transform
|
||||
${bgColors[toast.type]}
|
||||
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
`}
|
||||
style={{ minWidth: '300px', maxWidth: '400px' }}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{icon[toast.type]}
|
||||
</div>
|
||||
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => { setIsVisible(false); setTimeout(() => onClose(toast.id), 300); }}
|
||||
className="text-white/80 hover:text-white"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastContainer: React.FC<{ toasts: ToastMessage[], removeToast: (id: string) => void }> = ({ toasts, removeToast }) => {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
{toasts.map(t => (
|
||||
<Toast key={t.id} toast={t} onClose={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
Reference in New Issue
Block a user