mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49:06 +00:00
feat: implement frontend for calendar integration (Phase 3 & 4)
- Created MeetingSelection component for choosing between multiple active meetings
- Shows both active meetings and upcoming calendar events (30 min ahead)
- Displays meeting metadata with privacy controls (owner-only details)
- Supports creation of unscheduled meetings alongside calendar meetings
- Added waiting page for users joining before scheduled start time
- Shows countdown timer until meeting begins
- Auto-transitions to meeting when calendar event becomes active
- Handles early joining with proper routing
- Created collapsible info panel showing meeting details
- Displays calendar metadata (title, description, attendees)
- Shows participant count and duration
- Privacy-aware: sensitive info only visible to room owners
- Integrated ICS settings into room configuration dialog
- Test connection functionality with immediate feedback
- Manual sync trigger with detailed results
- Shows last sync time and ETag for monitoring
- Configurable sync intervals (1 min to 1 hour)
- New /room/{roomName} route for meeting selection
- Waiting room at /room/{roomName}/wait?eventId={id}
- Classic room page at /{roomName} with meeting info
- Uses sessionStorage to pass selected meeting between pages
- Added new endpoints for active/upcoming meetings
- Regenerated TypeScript client with latest OpenAPI spec
- Proper error handling and loading states
- Auto-refresh every 30 seconds for live updates
- Color-coded badges for meeting status
- Attendee status indicators (accepted/declined/tentative)
- Responsive design with Chakra UI components
- Clear visual hierarchy between active and upcoming meetings
- Smart truncation for long attendee lists
This completes the frontend implementation for calendar integration,
enabling users to seamlessly join scheduled meetings from their
calendar applications.
This commit is contained in:
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
createListCollection,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import useApi from "../../../lib/useApi";
|
||||
|
||||
interface ICSSettingsProps {
|
||||
roomId?: string;
|
||||
roomName?: string;
|
||||
icsUrl?: string;
|
||||
icsEnabled?: boolean;
|
||||
icsFetchInterval?: number;
|
||||
icsLastSync?: string;
|
||||
icsLastEtag?: string;
|
||||
onChange: (settings: Partial<ICSSettingsData>) => void;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export interface ICSSettingsData {
|
||||
ics_url: string;
|
||||
ics_enabled: boolean;
|
||||
ics_fetch_interval: number;
|
||||
}
|
||||
|
||||
const fetchIntervalOptions = [
|
||||
{ label: "1 minute", value: "1" },
|
||||
{ label: "5 minutes", value: "5" },
|
||||
{ label: "10 minutes", value: "10" },
|
||||
{ label: "30 minutes", value: "30" },
|
||||
{ label: "1 hour", value: "60" },
|
||||
];
|
||||
|
||||
export default function ICSSettings({
|
||||
roomId,
|
||||
roomName,
|
||||
icsUrl = "",
|
||||
icsEnabled = false,
|
||||
icsFetchInterval = 5,
|
||||
icsLastSync,
|
||||
icsLastEtag,
|
||||
onChange,
|
||||
isOwner = true,
|
||||
}: ICSSettingsProps) {
|
||||
const [syncStatus, setSyncStatus] = useState<
|
||||
"idle" | "syncing" | "success" | "error"
|
||||
>("idle");
|
||||
const [syncMessage, setSyncMessage] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<string>("");
|
||||
const api = useApi();
|
||||
|
||||
const fetchIntervalCollection = createListCollection({
|
||||
items: fetchIntervalOptions,
|
||||
});
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!api || !icsUrl || !roomName) return;
|
||||
|
||||
setSyncStatus("syncing");
|
||||
setTestResult("");
|
||||
|
||||
try {
|
||||
// First update the room with the ICS URL
|
||||
await api.v1RoomsPartialUpdate({
|
||||
roomId: roomId || roomName,
|
||||
requestBody: {
|
||||
ics_url: icsUrl,
|
||||
ics_enabled: true,
|
||||
ics_fetch_interval: icsFetchInterval,
|
||||
},
|
||||
});
|
||||
|
||||
// Then trigger a sync
|
||||
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||
|
||||
if (result.status === "success") {
|
||||
setSyncStatus("success");
|
||||
setTestResult(
|
||||
`Successfully synced! Found ${result.events_found} events.`,
|
||||
);
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setTestResult(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setTestResult(err.body?.detail || "Failed to test ICS connection");
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSync = async () => {
|
||||
if (!api || !roomName) return;
|
||||
|
||||
setSyncStatus("syncing");
|
||||
setSyncMessage("");
|
||||
|
||||
try {
|
||||
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||
|
||||
if (result.status === "success") {
|
||||
setSyncStatus("success");
|
||||
setSyncMessage(
|
||||
`Sync complete! Found ${result.events_found} events, ` +
|
||||
`created ${result.events_created}, updated ${result.events_updated}.`,
|
||||
);
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(err.body?.detail || "Failed to sync calendar");
|
||||
}
|
||||
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSyncStatus("idle");
|
||||
setSyncMessage("");
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return null; // ICS settings only visible to room owner
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch" mt={6}>
|
||||
<Text fontWeight="semibold" fontSize="lg">
|
||||
Calendar Integration (ICS)
|
||||
</Text>
|
||||
|
||||
<Field.Root>
|
||||
<Checkbox.Root
|
||||
checked={icsEnabled}
|
||||
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
{icsEnabled && (
|
||||
<>
|
||||
<Field.Root>
|
||||
<Field.Label>ICS Calendar URL</Field.Label>
|
||||
<Input
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={icsUrl}
|
||||
onChange={(e) => onChange({ ics_url: e.target.value })}
|
||||
/>
|
||||
<Field.HelperText>
|
||||
Enter the ICS URL from Google Calendar, Outlook, or other calendar
|
||||
services
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root>
|
||||
<Field.Label>Sync Interval</Field.Label>
|
||||
<Select.Root
|
||||
collection={fetchIntervalCollection}
|
||||
value={[icsFetchInterval.toString()]}
|
||||
onValueChange={(details) => {
|
||||
const value = parseInt(details.value[0]);
|
||||
onChange({ ics_fetch_interval: value });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{fetchIntervalOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Field.HelperText>
|
||||
How often to check for calendar updates
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{icsUrl && (
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={syncStatus === "syncing"}
|
||||
leftIcon={
|
||||
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
|
||||
}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
{roomName && icsLastSync && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleManualSync}
|
||||
disabled={syncStatus === "syncing"}
|
||||
leftIcon={<FaSync />}
|
||||
>
|
||||
Sync Now
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">{testResult}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{syncMessage && (
|
||||
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">{syncMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{icsLastSync && (
|
||||
<HStack spacing={4} fontSize="sm" color="gray.600">
|
||||
<HStack>
|
||||
<FaCheckCircle color="green" />
|
||||
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
|
||||
</HStack>
|
||||
{icsLastEtag && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
ETag: {icsLastEtag.slice(0, 8)}...
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { RoomList } from "./_components/RoomList";
|
||||
import { PaginationPage } from "../browse/_components/Pagination";
|
||||
import { assertExists } from "../../lib/utils";
|
||||
import ICSSettings from "./_components/ICSSettings";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
|
||||
@@ -70,6 +71,9 @@ const roomInitialState = {
|
||||
isShared: false,
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
icsUrl: "",
|
||||
icsEnabled: false,
|
||||
icsFetchInterval: 5,
|
||||
};
|
||||
|
||||
export default function RoomsList() {
|
||||
@@ -275,6 +279,9 @@ export default function RoomsList() {
|
||||
is_shared: room.isShared,
|
||||
webhook_url: room.webhookUrl,
|
||||
webhook_secret: room.webhookSecret,
|
||||
ics_url: room.icsUrl,
|
||||
ics_enabled: room.icsEnabled,
|
||||
ics_fetch_interval: room.icsFetchInterval,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -316,6 +323,22 @@ export default function RoomsList() {
|
||||
setShowWebhookSecret(false);
|
||||
setWebhookTestResult(null);
|
||||
|
||||
setRoom({
|
||||
name: roomData.name,
|
||||
zulipAutoPost: roomData.zulip_auto_post,
|
||||
zulipStream: roomData.zulip_stream,
|
||||
zulipTopic: roomData.zulip_topic,
|
||||
isLocked: roomData.is_locked,
|
||||
roomMode: roomData.room_mode,
|
||||
recordingType: roomData.recording_type,
|
||||
recordingTrigger: roomData.recording_trigger,
|
||||
isShared: roomData.is_shared,
|
||||
webhookUrl: roomData.webhook_url || "",
|
||||
webhookSecret: roomData.webhook_secret || "",
|
||||
icsUrl: roomData.ics_url || "",
|
||||
icsEnabled: roomData.ics_enabled || false,
|
||||
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
||||
});
|
||||
setEditRoomId(roomId);
|
||||
setIsEditing(true);
|
||||
setNameError("");
|
||||
@@ -763,6 +786,32 @@ export default function RoomsList() {
|
||||
<Checkbox.Label>Shared room</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
<ICSSettings
|
||||
roomId={editRoomId}
|
||||
roomName={room.name}
|
||||
icsUrl={room.icsUrl}
|
||||
icsEnabled={room.icsEnabled}
|
||||
icsFetchInterval={room.icsFetchInterval}
|
||||
onChange={(settings) => {
|
||||
setRoom({
|
||||
...room,
|
||||
icsUrl:
|
||||
settings.ics_url !== undefined
|
||||
? settings.ics_url
|
||||
: room.icsUrl,
|
||||
icsEnabled:
|
||||
settings.ics_enabled !== undefined
|
||||
? settings.ics_enabled
|
||||
: room.icsEnabled,
|
||||
icsFetchInterval:
|
||||
settings.ics_fetch_interval !== undefined
|
||||
? settings.ics_fetch_interval
|
||||
: room.icsFetchInterval,
|
||||
});
|
||||
}}
|
||||
isOwner={true}
|
||||
/>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onClick={handleCloseDialog}>
|
||||
|
||||
203
www/app/[roomName]/MeetingInfo.tsx
Normal file
203
www/app/[roomName]/MeetingInfo.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Divider,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
|
||||
import { Meeting } from "../api";
|
||||
|
||||
interface MeetingInfoProps {
|
||||
meeting: Meeting;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
|
||||
const formatDuration = (start: string | Date, end: string | Date) => {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const now = new Date();
|
||||
|
||||
// If meeting hasn't started yet
|
||||
if (startDate > now) {
|
||||
return `Scheduled for ${startDate.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
const durationMs = now.getTime() - startDate.getTime();
|
||||
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes} minutes`;
|
||||
};
|
||||
|
||||
const isCalendarMeeting = !!meeting.calendar_event_id;
|
||||
const metadata = meeting.calendar_metadata;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="56px"
|
||||
right="8px"
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
p={4}
|
||||
maxW="300px"
|
||||
zIndex={999}
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* Meeting Title */}
|
||||
<HStack>
|
||||
<Icon
|
||||
as={isCalendarMeeting ? FaCalendarAlt : FaInfoCircle}
|
||||
color="blue.500"
|
||||
/>
|
||||
<Text fontWeight="semibold" fontSize="md">
|
||||
{metadata?.title ||
|
||||
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Meeting Status */}
|
||||
<HStack spacing={2}>
|
||||
{meeting.is_active && (
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{isCalendarMeeting && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
Calendar
|
||||
</Badge>
|
||||
)}
|
||||
{meeting.is_locked && (
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Meeting Details */}
|
||||
<VStack align="stretch" spacing={2} fontSize="sm">
|
||||
{/* Participants */}
|
||||
<HStack>
|
||||
<Icon as={FaUsers} color="gray.500" />
|
||||
<Text>
|
||||
{meeting.num_clients}{" "}
|
||||
{meeting.num_clients === 1 ? "participant" : "participants"}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Duration */}
|
||||
<HStack>
|
||||
<Icon as={FaClock} color="gray.500" />
|
||||
<Text>
|
||||
Duration: {formatDuration(meeting.start_date, meeting.end_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Calendar Description (Owner only) */}
|
||||
{isOwner && metadata?.description && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="gray.600"
|
||||
mb={1}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.700">
|
||||
{metadata.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attendees (Owner only) */}
|
||||
{isOwner && metadata?.attendees && metadata.attendees.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="gray.600"
|
||||
mb={1}
|
||||
>
|
||||
Invited Attendees ({metadata.attendees.length})
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{metadata.attendees
|
||||
.slice(0, 5)
|
||||
.map((attendee: any, idx: number) => (
|
||||
<HStack key={idx} fontSize="xs">
|
||||
<Badge
|
||||
colorScheme={
|
||||
attendee.status === "ACCEPTED"
|
||||
? "green"
|
||||
: attendee.status === "DECLINED"
|
||||
? "red"
|
||||
: attendee.status === "TENTATIVE"
|
||||
? "yellow"
|
||||
: "gray"
|
||||
}
|
||||
fontSize="xs"
|
||||
size="sm"
|
||||
>
|
||||
{attendee.status?.charAt(0) || "?"}
|
||||
</Badge>
|
||||
<Text color="gray.700" isTruncated>
|
||||
{attendee.name || attendee.email}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{metadata.attendees.length > 5 && (
|
||||
<Text fontSize="xs" color="gray.500" fontStyle="italic">
|
||||
+{metadata.attendees.length - 5} more
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recording Info */}
|
||||
{meeting.recording_type !== "none" && (
|
||||
<>
|
||||
<Divider />
|
||||
<HStack fontSize="xs">
|
||||
<Badge colorScheme="red" fontSize="xs">
|
||||
Recording
|
||||
</Badge>
|
||||
<Text color="gray.600">
|
||||
{meeting.recording_type === "cloud" ? "Cloud" : "Local"}
|
||||
{meeting.recording_trigger !== "none" &&
|
||||
` (${meeting.recording_trigger})`}
|
||||
</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Meeting Times */}
|
||||
<Divider />
|
||||
<VStack align="stretch" spacing={1} fontSize="xs" color="gray.600">
|
||||
<Text>Start: {new Date(meeting.start_date).toLocaleString()}</Text>
|
||||
<Text>End: {new Date(meeting.end_date).toLocaleString()}</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
335
www/app/[roomName]/MeetingSelection.tsx
Normal file
335
www/app/[roomName]/MeetingSelection.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Spinner,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Badge,
|
||||
Divider,
|
||||
Icon,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
|
||||
import { Meeting, CalendarEventResponse } from "../api";
|
||||
import useApi from "../lib/useApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface MeetingSelectionProps {
|
||||
roomName: string;
|
||||
isOwner: boolean;
|
||||
onMeetingSelect: (meeting: Meeting) => void;
|
||||
onCreateUnscheduled: () => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (date: string | Date) => {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCountdown = (startTime: string | Date) => {
|
||||
const now = new Date();
|
||||
const start = new Date(startTime);
|
||||
const diff = start.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return "Starting now";
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `Starts in ${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
return `Starts in ${minutes} minutes`;
|
||||
};
|
||||
|
||||
export default function MeetingSelection({
|
||||
roomName,
|
||||
isOwner,
|
||||
onMeetingSelect,
|
||||
onCreateUnscheduled,
|
||||
}: MeetingSelectionProps) {
|
||||
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([]);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEventResponse[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const api = useApi();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch active meetings
|
||||
const active = await api.v1RoomsListActiveMeetings({ roomName });
|
||||
setActiveMeetings(active);
|
||||
|
||||
// Fetch upcoming calendar events (30 min ahead)
|
||||
const upcoming = await api.v1RoomsListUpcomingMeetings({
|
||||
roomName,
|
||||
minutesAhead: 30,
|
||||
});
|
||||
setUpcomingEvents(upcoming);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch meetings:", err);
|
||||
setError("Failed to load meetings. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMeetings();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchMeetings, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [api, roomName]);
|
||||
|
||||
const handleJoinMeeting = async (meetingId: string) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const meeting = await api.v1RoomsJoinMeeting({
|
||||
roomName,
|
||||
meetingId,
|
||||
});
|
||||
onMeetingSelect(meeting);
|
||||
} catch (err) {
|
||||
console.error("Failed to join meeting:", err);
|
||||
setError("Failed to join meeting. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinUpcoming = (event: CalendarEventResponse) => {
|
||||
// Navigate to waiting page with event info
|
||||
router.push(`/room/${roomName}/wait?eventId=${event.id}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box p={8} textAlign="center">
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text mt={4}>Loading meetings...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch" p={6}>
|
||||
<Box>
|
||||
<Text fontSize="2xl" fontWeight="bold" mb={4}>
|
||||
Select a Meeting
|
||||
</Text>
|
||||
|
||||
{/* Active Meetings */}
|
||||
{activeMeetings.length > 0 && (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="semibold" mb={3}>
|
||||
Active Meetings
|
||||
</Text>
|
||||
<VStack spacing={3} mb={6}>
|
||||
{activeMeetings.map((meeting) => (
|
||||
<Card key={meeting.id} width="100%" variant="outline">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FaCalendarAlt} color="blue.500" />
|
||||
<Text fontWeight="semibold">
|
||||
{meeting.calendar_metadata?.title || "Meeting"}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{isOwner && meeting.calendar_metadata?.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{meeting.calendar_metadata.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={4} fontSize="sm" color="gray.500">
|
||||
<HStack>
|
||||
<Icon as={FaUsers} />
|
||||
<Text>{meeting.num_clients} participants</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon as={FaClock} />
|
||||
<Text>
|
||||
Started {formatDateTime(meeting.start_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{isOwner && meeting.calendar_metadata?.attendees && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{meeting.calendar_metadata.attendees
|
||||
.slice(0, 3)
|
||||
.map((attendee: any, idx: number) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
colorScheme="green"
|
||||
fontSize="xs"
|
||||
>
|
||||
{attendee.name || attendee.email}
|
||||
</Badge>
|
||||
))}
|
||||
{meeting.calendar_metadata.attendees.length > 3 && (
|
||||
<Badge colorScheme="gray" fontSize="xs">
|
||||
+
|
||||
{meeting.calendar_metadata.attendees.length - 3}{" "}
|
||||
more
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="md"
|
||||
onClick={() => handleJoinMeeting(meeting.id)}
|
||||
>
|
||||
Join Now
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upcoming Meetings */}
|
||||
{upcomingEvents.length > 0 && (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="semibold" mb={3}>
|
||||
Upcoming Meetings
|
||||
</Text>
|
||||
<VStack spacing={3} mb={6}>
|
||||
{upcomingEvents.map((event) => (
|
||||
<Card
|
||||
key={event.id}
|
||||
width="100%"
|
||||
variant="outline"
|
||||
bg="gray.50"
|
||||
>
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FaCalendarAlt} color="orange.500" />
|
||||
<Text fontWeight="semibold">
|
||||
{event.title || "Scheduled Meeting"}
|
||||
</Text>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{formatCountdown(event.start_time)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{isOwner && event.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={4} fontSize="sm" color="gray.500">
|
||||
<Text>
|
||||
{formatDateTime(event.start_time)} -{" "}
|
||||
{formatDateTime(event.end_time)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{isOwner && event.attendees && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{event.attendees
|
||||
.slice(0, 3)
|
||||
.map((attendee: any, idx: number) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
colorScheme="purple"
|
||||
fontSize="xs"
|
||||
>
|
||||
{attendee.name || attendee.email}
|
||||
</Badge>
|
||||
))}
|
||||
{event.attendees.length > 3 && (
|
||||
<Badge colorScheme="gray" fontSize="xs">
|
||||
+{event.attendees.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="orange"
|
||||
size="md"
|
||||
onClick={() => handleJoinUpcoming(event)}
|
||||
>
|
||||
Join Early
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
{/* Create Unscheduled Meeting */}
|
||||
<Card width="100%" variant="filled" bg="gray.100">
|
||||
<CardBody>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Create a new meeting room that's not on the calendar
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
leftIcon={<FaPlus />}
|
||||
colorScheme="green"
|
||||
onClick={onCreateUnscheduled}
|
||||
>
|
||||
Create Meeting
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -24,10 +24,13 @@ import { notFound } from "next/navigation";
|
||||
import { useRecordingConsent } from "../recordingConsentContext";
|
||||
import { useMeetingAudioConsent } from "../lib/apiHooks";
|
||||
import type { components } from "../reflector-api";
|
||||
import useApi from "../lib/useApi";
|
||||
import { FaBars, FaInfoCircle } from "react-icons/fa6";
|
||||
import MeetingInfo from "./MeetingInfo";
|
||||
import { useAuth } from "../lib/AuthProvider";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import { useAuth } from "../lib/AuthProvider";
|
||||
type Room = components["schemas"]["Room"];
|
||||
|
||||
export type RoomDetails = {
|
||||
params: {
|
||||
@@ -263,6 +266,9 @@ export default function Room(details: RoomDetails) {
|
||||
const status = useAuth().status;
|
||||
const isAuthenticated = status === "authenticated";
|
||||
const isLoading = status === "loading" || meeting.loading;
|
||||
const [showMeetingInfo, setShowMeetingInfo] = useState(false);
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const api = useApi();
|
||||
|
||||
const roomUrl = meeting?.response?.host_room_url
|
||||
? meeting?.response?.host_room_url
|
||||
@@ -276,6 +282,15 @@ export default function Room(details: RoomDetails) {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
// Fetch room details
|
||||
useEffect(() => {
|
||||
if (!api || !roomName) return;
|
||||
|
||||
api.v1RoomsRetrieve({ roomName }).then(setRoom).catch(console.error);
|
||||
}, [api, roomName]);
|
||||
|
||||
const isOwner = session?.user?.id === room?.user_id;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
@@ -327,6 +342,25 @@ export default function Room(details: RoomDetails) {
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
{meeting?.response && (
|
||||
<>
|
||||
<Button
|
||||
position="absolute"
|
||||
top="56px"
|
||||
right={showMeetingInfo ? "320px" : "8px"}
|
||||
zIndex={1000}
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
|
||||
leftIcon={<Icon as={FaInfoCircle} />}
|
||||
>
|
||||
Meeting Info
|
||||
</Button>
|
||||
{showMeetingInfo && (
|
||||
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -40,6 +40,20 @@ const useRoomMeeting = (
|
||||
useEffect(() => {
|
||||
if (!roomName) return;
|
||||
|
||||
// Check if meeting was pre-selected from meeting selection page
|
||||
const storedMeeting = sessionStorage.getItem(`meeting_${roomName}`);
|
||||
if (storedMeeting) {
|
||||
try {
|
||||
const meeting = JSON.parse(storedMeeting);
|
||||
sessionStorage.removeItem(`meeting_${roomName}`); // Clean up
|
||||
setResponse(meeting);
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored meeting:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const createMeeting = async () => {
|
||||
try {
|
||||
const result = await createMeetingMutation.mutateAsync({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
147
www/app/room/[roomName]/page.tsx
Normal file
147
www/app/room/[roomName]/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Spinner, VStack, Text } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useApi from "../../lib/useApi";
|
||||
import useSessionStatus from "../../lib/useSessionStatus";
|
||||
import MeetingSelection from "../../[roomName]/MeetingSelection";
|
||||
import { Meeting, Room } from "../../api";
|
||||
|
||||
interface RoomPageProps {
|
||||
params: {
|
||||
roomName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RoomPage({ params }: RoomPageProps) {
|
||||
const { roomName } = params;
|
||||
const router = useRouter();
|
||||
const api = useApi();
|
||||
const { data: session } = useSessionStatus();
|
||||
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkingMeetings, setCheckingMeetings] = useState(false);
|
||||
|
||||
const isOwner = session?.user?.id === room?.user_id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
// Get room details
|
||||
const roomData = await api.v1RoomsRetrieve({ roomName });
|
||||
setRoom(roomData);
|
||||
|
||||
// Check if we should show meeting selection
|
||||
if (roomData.ics_enabled) {
|
||||
setCheckingMeetings(true);
|
||||
|
||||
// Check for active meetings
|
||||
const activeMeetings = await api.v1RoomsListActiveMeetings({
|
||||
roomName,
|
||||
});
|
||||
|
||||
// Check for upcoming meetings
|
||||
const upcomingEvents = await api.v1RoomsListUpcomingMeetings({
|
||||
roomName,
|
||||
minutesAhead: 30,
|
||||
});
|
||||
|
||||
// If there's only one active meeting and no upcoming, auto-join
|
||||
if (activeMeetings.length === 1 && upcomingEvents.length === 0) {
|
||||
handleMeetingSelect(activeMeetings[0]);
|
||||
} else if (
|
||||
activeMeetings.length === 0 &&
|
||||
upcomingEvents.length === 0
|
||||
) {
|
||||
// No meetings, create unscheduled
|
||||
handleCreateUnscheduled();
|
||||
}
|
||||
// Otherwise, show selection UI (handled by render)
|
||||
} else {
|
||||
// ICS not enabled, use traditional flow
|
||||
handleCreateUnscheduled();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch room:", err);
|
||||
// Room not found or error
|
||||
router.push("/rooms");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setCheckingMeetings(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoom();
|
||||
}, [api, roomName]);
|
||||
|
||||
const handleMeetingSelect = (meeting: Meeting) => {
|
||||
// Navigate to the classic room page with the meeting
|
||||
// Store meeting in session storage for the classic page to use
|
||||
sessionStorage.setItem(`meeting_${roomName}`, JSON.stringify(meeting));
|
||||
router.push(`/${roomName}`);
|
||||
};
|
||||
|
||||
const handleCreateUnscheduled = async () => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
// Create a new unscheduled meeting
|
||||
const meeting = await api.v1RoomsCreateMeeting({ roomName });
|
||||
handleMeetingSelect(meeting);
|
||||
} catch (err) {
|
||||
console.error("Failed to create meeting:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || checkingMeetings) {
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<Text fontSize="lg">Room not found</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show meeting selection if ICS is enabled and we have multiple options
|
||||
if (room.ics_enabled) {
|
||||
return (
|
||||
<Box minH="100vh" bg="gray.50">
|
||||
<MeetingSelection
|
||||
roomName={roomName}
|
||||
isOwner={isOwner}
|
||||
onMeetingSelect={handleMeetingSelect}
|
||||
onCreateUnscheduled={handleCreateUnscheduled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Should not reach here - redirected above
|
||||
return null;
|
||||
}
|
||||
277
www/app/room/[roomName]/wait/page.tsx
Normal file
277
www/app/room/[roomName]/wait/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Progress,
|
||||
Card,
|
||||
CardBody,
|
||||
Button,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { FaClock, FaArrowLeft } from "react-icons/fa";
|
||||
import useApi from "../../../lib/useApi";
|
||||
import { CalendarEventResponse } from "../../../api";
|
||||
|
||||
interface WaitingPageProps {
|
||||
params: {
|
||||
roomName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WaitingPage({ params }: WaitingPageProps) {
|
||||
const { roomName } = params;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const eventId = searchParams.get("eventId");
|
||||
|
||||
const [event, setEvent] = useState<CalendarEventResponse | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checkingMeeting, setCheckingMeeting] = useState(false);
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !eventId) return;
|
||||
|
||||
const fetchEvent = async () => {
|
||||
try {
|
||||
const events = await api.v1RoomsListUpcomingMeetings({
|
||||
roomName,
|
||||
minutesAhead: 60,
|
||||
});
|
||||
|
||||
const targetEvent = events.find((e) => e.id === eventId);
|
||||
if (targetEvent) {
|
||||
setEvent(targetEvent);
|
||||
} else {
|
||||
// Event not found or already started
|
||||
router.push(`/room/${roomName}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch event:", err);
|
||||
router.push(`/room/${roomName}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvent();
|
||||
}, [api, eventId, roomName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date();
|
||||
const start = new Date(event.start_time);
|
||||
const diff = Math.max(0, start.getTime() - now.getTime());
|
||||
|
||||
setTimeRemaining(diff);
|
||||
|
||||
// Check if meeting has started
|
||||
if (diff <= 0) {
|
||||
checkForActiveMeeting();
|
||||
}
|
||||
};
|
||||
|
||||
const checkForActiveMeeting = async () => {
|
||||
if (!api || checkingMeeting) return;
|
||||
|
||||
setCheckingMeeting(true);
|
||||
try {
|
||||
// Check for active meetings
|
||||
const activeMeetings = await api.v1RoomsListActiveMeetings({
|
||||
roomName,
|
||||
});
|
||||
|
||||
// Find meeting for this calendar event
|
||||
const calendarMeeting = activeMeetings.find(
|
||||
(m) => m.calendar_event_id === eventId,
|
||||
);
|
||||
|
||||
if (calendarMeeting) {
|
||||
// Meeting is now active, join it
|
||||
const meeting = await api.v1RoomsJoinMeeting({
|
||||
roomName,
|
||||
meetingId: calendarMeeting.id,
|
||||
});
|
||||
|
||||
// Navigate to the meeting room
|
||||
router.push(`/${roomName}?meetingId=${meeting.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check for active meeting:", err);
|
||||
} finally {
|
||||
setCheckingMeeting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update countdown every second
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
|
||||
// Check for meeting every 10 seconds when close to start time
|
||||
let checkInterval: NodeJS.Timeout | null = null;
|
||||
if (timeRemaining < 60000) {
|
||||
// Less than 1 minute
|
||||
checkInterval = setInterval(checkForActiveMeeting, 10000);
|
||||
}
|
||||
|
||||
updateCountdown(); // Initial update
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
};
|
||||
}, [event, api, eventId, roomName, checkingMeeting]);
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getProgressValue = () => {
|
||||
if (!event) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const created = new Date(event.created_at);
|
||||
const start = new Date(event.start_time);
|
||||
const totalTime = start.getTime() - created.getTime();
|
||||
const elapsed = now.getTime() - created.getTime();
|
||||
|
||||
return Math.min(100, (elapsed / totalTime) * 100);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text>Loading meeting details...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg">Meeting not found</Text>
|
||||
<Button
|
||||
leftIcon={<FaArrowLeft />}
|
||||
onClick={() => router.push(`/room/${roomName}`)}
|
||||
>
|
||||
Back to Room
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<Card maxW="lg" width="100%" mx={4}>
|
||||
<CardBody>
|
||||
<VStack spacing={6} py={4}>
|
||||
<Icon as={FaClock} boxSize={16} color="blue.500" />
|
||||
|
||||
<VStack spacing={2}>
|
||||
<Text fontSize="2xl" fontWeight="bold">
|
||||
{event.title || "Scheduled Meeting"}
|
||||
</Text>
|
||||
<Text color="gray.600" textAlign="center">
|
||||
The meeting will start automatically when it's time
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Box width="100%">
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
color="blue.600"
|
||||
>
|
||||
{formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={getProgressValue()}
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
mt={4}
|
||||
borderRadius="full"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{event.description && (
|
||||
<Box width="100%" p={4} bg="gray.100" borderRadius="md">
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={1}>
|
||||
Meeting Description
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.700">
|
||||
{event.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VStack spacing={3} width="100%">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Scheduled for {new Date(event.start_time).toLocaleString()}
|
||||
</Text>
|
||||
|
||||
{checkingMeeting && (
|
||||
<HStack spacing={2}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text fontSize="sm" color="blue.600">
|
||||
Checking if meeting has started...
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<FaArrowLeft />}
|
||||
onClick={() => router.push(`/room/${roomName}`)}
|
||||
width="100%"
|
||||
>
|
||||
Back to Meeting Selection
|
||||
</Button>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user