feat: complete history, attendees list, and smart templates
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user