Release 1.1: Pre-meeting models, Calendar improvements, Logs, Compact Recorder

This commit is contained in:
michael.borak
2026-01-20 17:15:14 +01:00
parent f61bcf1cc3
commit 79f509951c
16 changed files with 2011 additions and 321 deletions

View File

@@ -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>