WIP: Migrate calendar integration frontend to React Query

- Migrate all calendar components from useApi to React Query hooks
- Fix Chakra UI v3 compatibility issues (Card, Progress, spacing props, leftIcon)
- Update backend Meeting model to include calendar fields
- Replace imperative API calls with declarative React Query patterns
- Remove old OpenAPI generated files that conflict with new structure
This commit is contained in:
2025-09-05 12:14:47 -06:00
parent 575f20fee2
commit ccc240eddf
15 changed files with 1976 additions and 4708 deletions

View File

@@ -1,7 +1,7 @@
import logging
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal, Optional
from typing import Annotated, Any, Literal, Optional
import asyncpg.exceptions
from fastapi import APIRouter, Depends, HTTPException
@@ -62,7 +62,20 @@ class Meeting(BaseModel):
host_room_url: str
start_date: datetime
end_date: datetime
user_id: str | None = None
room_id: str | None = None
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
recording_trigger: Literal[
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
last_participant_left_at: datetime | None = None
grace_period_minutes: int = 15
class CreateRoom(BaseModel):

879
server/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,14 @@ import {
Checkbox,
Button,
Text,
Alert,
AlertIcon,
AlertTitle,
Badge,
createListCollection,
Spinner,
Box,
} from "@chakra-ui/react";
import { useState } from "react";
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import useApi from "../../../lib/useApi";
import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks";
interface ICSSettingsProps {
roomId?: string;
@@ -60,31 +58,35 @@ export default function ICSSettings({
>("idle");
const [syncMessage, setSyncMessage] = useState<string>("");
const [testResult, setTestResult] = useState<string>("");
const api = useApi();
// React Query hooks
const syncMutation = useRoomIcsSync();
const statusQuery = useRoomIcsStatus(roomName || null);
const fetchIntervalCollection = createListCollection({
items: fetchIntervalOptions,
});
const handleTestConnection = async () => {
if (!api || !icsUrl || !roomName) return;
if (!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,
},
// First notify parent to update the room with the ICS URL
onChange({
ics_url: icsUrl,
ics_enabled: true,
ics_fetch_interval: icsFetchInterval,
});
// Then trigger a sync
const result = await api.v1RoomsTriggerIcsSync({ roomName });
const result = await syncMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
if (result.status === "success") {
setSyncStatus("success");
@@ -102,13 +104,17 @@ export default function ICSSettings({
};
const handleManualSync = async () => {
if (!api || !roomName) return;
if (!roomName) return;
setSyncStatus("syncing");
setSyncMessage("");
try {
const result = await api.v1RoomsTriggerIcsSync({ roomName });
const result = await syncMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
if (result.status === "success") {
setSyncStatus("success");
@@ -137,7 +143,7 @@ export default function ICSSettings({
}
return (
<VStack spacing={4} align="stretch" mt={6}>
<VStack gap={4} align="stretch" mt={6}>
<Text fontWeight="semibold" fontSize="lg">
Calendar Integration (ICS)
</Text>
@@ -145,7 +151,7 @@ export default function ICSSettings({
<Field.Root>
<Checkbox.Root
checked={icsEnabled}
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
onCheckedChange={(e) => onChange({ ics_enabled: !!e.checked })}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
@@ -197,16 +203,14 @@ export default function ICSSettings({
</Field.Root>
{icsUrl && (
<HStack spacing={3}>
<HStack gap={3}>
<Button
size="sm"
variant="outline"
onClick={handleTestConnection}
disabled={syncStatus === "syncing"}
leftIcon={
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
}
>
{syncStatus === "syncing" && <Spinner size="sm" />}
Test Connection
</Button>
@@ -216,8 +220,8 @@ export default function ICSSettings({
variant="outline"
onClick={handleManualSync}
disabled={syncStatus === "syncing"}
leftIcon={<FaSync />}
>
<FaSync />
Sync Now
</Button>
)}
@@ -225,21 +229,41 @@ export default function ICSSettings({
)}
{testResult && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{testResult}</Text>
</Alert>
<Box
p={3}
borderRadius="md"
bg={syncStatus === "success" ? "green.50" : "red.50"}
borderLeft="4px solid"
borderColor={syncStatus === "success" ? "green.400" : "red.400"}
>
<Text
fontSize="sm"
color={syncStatus === "success" ? "green.800" : "red.800"}
>
{testResult}
</Text>
</Box>
)}
{syncMessage && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{syncMessage}</Text>
</Alert>
<Box
p={3}
borderRadius="md"
bg={syncStatus === "success" ? "green.50" : "red.50"}
borderLeft="4px solid"
borderColor={syncStatus === "success" ? "green.400" : "red.400"}
>
<Text
fontSize="sm"
color={syncStatus === "success" ? "green.800" : "red.800"}
>
{syncMessage}
</Text>
</Box>
)}
{icsLastSync && (
<HStack spacing={4} fontSize="sm" color="gray.600">
<HStack gap={4} fontSize="sm" color="gray.600">
<HStack>
<FaCheckCircle color="green" />
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>

View File

@@ -141,6 +141,9 @@ export default function RoomsList() {
isShared: detailedEditedRoom.is_shared,
webhookUrl: detailedEditedRoom.webhook_url || "",
webhookSecret: detailedEditedRoom.webhook_secret || "",
icsUrl: detailedEditedRoom.ics_url || "",
icsEnabled: detailedEditedRoom.ics_enabled || false,
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
}
: null,
[detailedEditedRoom],
@@ -323,7 +326,7 @@ export default function RoomsList() {
setShowWebhookSecret(false);
setWebhookTestResult(null);
setRoom({
setRoomInput({
name: roomData.name,
zulipAutoPost: roomData.zulip_auto_post,
zulipStream: roomData.zulip_stream,
@@ -788,13 +791,13 @@ export default function RoomsList() {
</Field.Root>
<ICSSettings
roomId={editRoomId}
roomId={editRoomId ?? undefined}
roomName={room.name}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoom({
setRoomInput({
...room,
icsUrl:
settings.ics_url !== undefined

View File

@@ -1,14 +1,8 @@
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Divider,
} from "@chakra-ui/react";
import { Box, VStack, HStack, Text, Badge, Icon } from "@chakra-ui/react";
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
import { Meeting } from "../api";
import type { components } from "../reflector-api";
type Meeting = components["schemas"]["Meeting"];
interface MeetingInfoProps {
meeting: Meeting;
@@ -52,7 +46,7 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
maxW="300px"
zIndex={999}
>
<VStack align="stretch" spacing={3}>
<VStack align="stretch" gap={3}>
{/* Meeting Title */}
<HStack>
<Icon
@@ -60,13 +54,13 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
color="blue.500"
/>
<Text fontWeight="semibold" fontSize="md">
{metadata?.title ||
{(metadata as any)?.title ||
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
</Text>
</HStack>
{/* Meeting Status */}
<HStack spacing={2}>
<HStack gap={2}>
{meeting.is_active && (
<Badge colorScheme="green" fontSize="xs">
Active
@@ -84,10 +78,10 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
)}
</HStack>
<Divider />
<Box h="1px" bg="gray.200" />
{/* Meeting Details */}
<VStack align="stretch" spacing={2} fontSize="sm">
<VStack align="stretch" gap={2} fontSize="sm">
{/* Participants */}
<HStack>
<Icon as={FaUsers} color="gray.500" />
@@ -106,9 +100,9 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
</HStack>
{/* Calendar Description (Owner only) */}
{isOwner && metadata?.description && (
{isOwner && (metadata as any)?.description && (
<>
<Divider />
<Box h="1px" bg="gray.200" />
<Box>
<Text
fontWeight="semibold"
@@ -119,64 +113,66 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
Description
</Text>
<Text fontSize="xs" color="gray.700">
{metadata.description}
{(metadata as any).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>
</>
)}
{isOwner &&
(metadata as any)?.attendees &&
(metadata as any).attendees.length > 0 && (
<>
<Box h="1px" bg="gray.200" />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Invited Attendees ({(metadata as any).attendees.length})
</Text>
<VStack align="stretch" gap={1}>
{(metadata as any).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" truncate>
{attendee.name || attendee.email}
</Text>
</HStack>
))}
{(metadata as any).attendees.length > 5 && (
<Text fontSize="xs" color="gray.500" fontStyle="italic">
+{(metadata as any).attendees.length - 5} more
</Text>
)}
</VStack>
</Box>
</>
)}
{/* Recording Info */}
{meeting.recording_type !== "none" && (
<>
<Divider />
<Box h="1px" bg="gray.200" />
<HStack fontSize="xs">
<Badge colorScheme="red" fontSize="xs">
Recording
@@ -192,8 +188,8 @@ export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
</VStack>
{/* Meeting Times */}
<Divider />
<VStack align="stretch" spacing={1} fontSize="xs" color="gray.600">
<Box h="1px" bg="gray.200" />
<VStack align="stretch" gap={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>

View File

@@ -7,23 +7,22 @@ import {
Text,
Button,
Spinner,
Card,
CardBody,
CardHeader,
Badge,
Divider,
Icon,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import React from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { Meeting, CalendarEventResponse } from "../api";
import useApi from "../lib/useApi";
import type { components } from "../reflector-api";
import {
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomJoinMeeting,
} from "../lib/apiHooks";
import { useRouter } from "next/navigation";
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface MeetingSelectionProps {
roomName: string;
isOwner: boolean;
@@ -63,61 +62,33 @@ export default function MeetingSelection({
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;
// Use React Query hooks for data fetching
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
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 activeMeetings = activeMeetingsQuery.data || [];
const upcomingEvents = upcomingMeetingsQuery.data || [];
const loading =
activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading;
const error = activeMeetingsQuery.error || upcomingMeetingsQuery.error;
const handleJoinMeeting = async (meetingId: string) => {
if (!api) return;
try {
const meeting = await api.v1RoomsJoinMeeting({
roomName,
meetingId,
const meeting = await joinMeetingMutation.mutateAsync({
params: {
path: {
room_name: roomName,
meeting_id: meetingId,
},
},
});
onMeetingSelect(meeting);
} catch (err) {
console.error("Failed to join meeting:", err);
setError("Failed to join meeting. Please try again.");
// Handle error appropriately since we don't have setError anymore
}
};
@@ -137,16 +108,23 @@ export default function MeetingSelection({
if (error) {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<Box
p={4}
borderRadius="md"
bg="red.50"
borderLeft="4px solid"
borderColor="red.400"
>
<Text fontWeight="semibold" color="red.800">
Error
</Text>
<Text color="red.700">{"Failed to load meetings"}</Text>
</Box>
);
}
return (
<VStack spacing={6} align="stretch" p={6}>
<VStack gap={6} align="stretch" p={6}>
<Box>
<Text fontSize="2xl" fontWeight="bold" mb={4}>
Select a Meeting
@@ -158,41 +136,50 @@ export default function MeetingSelection({
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Active Meetings
</Text>
<VStack spacing={3} mb={6}>
<VStack gap={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>
<Box
key={meeting.id}
width="100%"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{(meeting.calendar_metadata as any)?.title ||
"Meeting"}
</Text>
</HStack>
{isOwner && meeting.calendar_metadata?.description && (
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600">
{meeting.calendar_metadata.description}
{(meeting.calendar_metadata as any).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 gap={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
{isOwner &&
(meeting.calendar_metadata as any)?.attendees && (
<HStack gap={2} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
@@ -203,27 +190,28 @@ export default function MeetingSelection({
{attendee.name || attendee.email}
</Badge>
))}
{meeting.calendar_metadata.attendees.length > 3 && (
{(meeting.calendar_metadata as any).attendees
.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{meeting.calendar_metadata.attendees.length - 3}{" "}
{(meeting.calendar_metadata as any).attendees
.length - 3}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
</VStack>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
</HStack>
</CardBody>
</Card>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
</HStack>
</Box>
))}
</VStack>
</>
@@ -235,100 +223,96 @@ export default function MeetingSelection({
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Upcoming Meetings
</Text>
<VStack spacing={3} mb={6}>
<VStack gap={3} mb={6}>
{upcomingEvents.map((event) => (
<Card
<Box
key={event.id}
width="100%"
variant="outline"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
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>
<HStack justify="space-between" align="start">
<VStack align="start" gap={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>
)}
{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>
<HStack gap={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
{isOwner && event.attendees && (
<HStack gap={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>
)}
</HStack>
)}
</VStack>
))}
{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>
<Button
variant="outline"
colorScheme="orange"
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
</Button>
</HStack>
</Box>
))}
</VStack>
</>
)}
<Divider my={6} />
<Box h="1px" bg="gray.200" 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 width="100%" bg="gray.100" borderRadius="md" p={4}>
<HStack justify="space-between" align="center">
<VStack align="start" gap={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 colorScheme="green" onClick={onCreateUnscheduled}>
<FaPlus />
Create Meeting
</Button>
</HStack>
</Box>
</Box>
</VStack>
);

View File

@@ -22,10 +22,10 @@ import useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation";
import { notFound } from "next/navigation";
import { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent } from "../lib/apiHooks";
import { useMeetingAudioConsent, useRoomGetByName } from "../lib/apiHooks";
import type { components } from "../reflector-api";
import useApi from "../lib/useApi";
import { FaBars, FaInfoCircle } from "react-icons/fa6";
import { FaBars } from "react-icons/fa6";
import { FaInfoCircle } from "react-icons/fa";
import MeetingInfo from "./MeetingInfo";
import { useAuth } from "../lib/AuthProvider";
@@ -263,12 +263,15 @@ export default function Room(details: RoomDetails) {
const roomName = details.params.roomName;
const meeting = useRoomMeeting(roomName);
const router = useRouter();
const status = useAuth().status;
const auth = useAuth();
const status = auth.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();
// Fetch room details using React Query
const roomQuery = useRoomGetByName(roomName);
const room = roomQuery.data;
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
@@ -282,14 +285,8 @@ 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;
const isOwner =
auth.status === "authenticated" ? auth.user?.id === room?.user_id : false;
useEffect(() => {
if (
@@ -352,8 +349,8 @@ export default function Room(details: RoomDetails) {
colorPalette="blue"
size="sm"
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
leftIcon={<Icon as={FaInfoCircle} />}
>
<Icon as={FaInfoCircle} />
Meeting Info
</Button>
{showMeetingInfo && (

View File

@@ -47,7 +47,6 @@ const useRoomMeeting = (
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);

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

View File

@@ -616,3 +616,97 @@ export function useRoomsCreateMeeting() {
},
});
}
// Calendar integration hooks
export function useRoomGetByName(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms",
{
params: {
query: { page: 1 }, // We'll need to filter by room name on the client side
},
},
{
enabled: !!roomName && isAuthenticated,
select: (data) => data.items?.find((room) => room.name === roomName),
},
);
}
export function useRoomUpcomingMeetings(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/upcoming",
{
params: {
path: { room_name: roomName || "" },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
export function useRoomActiveMeetings(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/active",
{
params: {
path: { room_name: roomName || "" },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
export function useRoomJoinMeeting() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/join",
{
onError: (error) => {
setError(error as Error, "There was an error joining the meeting");
},
},
);
}
export function useRoomIcsSync() {
const { setError } = useError();
return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", {
onError: (error) => {
setError(error as Error, "There was an error syncing the calendar");
},
});
}
export function useRoomIcsStatus(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/ics/status",
{
params: {
path: { room_name: roomName || "" },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}

View File

@@ -115,6 +115,114 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/ics/sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Rooms Sync Ics */
post: operations["v1_rooms_sync_ics"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/ics/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms Ics Status */
get: operations["v1_rooms_ics_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms List Meetings */
get: operations["v1_rooms_list_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/upcoming": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms List Upcoming Meetings */
get: operations["v1_rooms_list_upcoming_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/active": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Rooms List Active Meetings
* @description List all active meetings for a room (supports multiple active meetings)
*/
get: operations["v1_rooms_list_active_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/{meeting_id}/join": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Rooms Join Meeting
* @description Join a specific meeting by ID
*/
post: operations["v1_rooms_join_meeting"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts": {
parameters: {
query?: never;
@@ -505,6 +613,52 @@ export interface components {
*/
chunk: string;
};
/** CalendarEventResponse */
CalendarEventResponse: {
/** Id */
id: string;
/** Room Id */
room_id: string;
/** Ics Uid */
ics_uid: string;
/** Title */
title?: string | null;
/** Description */
description?: string | null;
/**
* Start Time
* Format: date-time
*/
start_time: string;
/**
* End Time
* Format: date-time
*/
end_time: string;
/** Attendees */
attendees?:
| {
[key: string]: unknown;
}[]
| null;
/** Location */
location?: string | null;
/**
* Last Synced
* Format: date-time
*/
last_synced: string;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
};
/** CreateParticipant */
CreateParticipant: {
/** Speaker */
@@ -536,6 +690,18 @@ export interface components {
webhook_url: string;
/** Webhook Secret */
webhook_secret: string;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
};
/** CreateTranscript */
CreateTranscript: {
@@ -748,6 +914,51 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** ICSStatus */
ICSStatus: {
/** Status */
status: string;
/** Last Sync */
last_sync?: string | null;
/** Next Sync */
next_sync?: string | null;
/** Last Etag */
last_etag?: string | null;
/**
* Events Count
* @default 0
*/
events_count: number;
};
/** ICSSyncResult */
ICSSyncResult: {
/** Status */
status: string;
/** Hash */
hash?: string | null;
/**
* Events Found
* @default 0
*/
events_found: number;
/**
* Events Created
* @default 0
*/
events_created: number;
/**
* Events Updated
* @default 0
*/
events_updated: number;
/**
* Events Deleted
* @default 0
*/
events_deleted: number;
/** Error */
error?: string | null;
};
/** Meeting */
Meeting: {
/** Id */
@@ -768,12 +979,60 @@ export interface components {
* Format: date-time
*/
end_date: string;
/** User Id */
user_id?: string | null;
/** Room Id */
room_id?: string | null;
/**
* Is Locked
* @default false
*/
is_locked: boolean;
/**
* Room Mode
* @default normal
* @enum {string}
*/
room_mode: "normal" | "group";
/**
* Recording Type
* @default cloud
* @enum {string}
*/
recording_type: "none" | "local" | "cloud";
/**
* Recording Trigger
* @default automatic-2nd-participant
* @enum {string}
*/
recording_trigger:
| "none"
| "prompt"
| "automatic"
| "automatic-2nd-participant";
/**
* Num Clients
* @default 0
*/
num_clients: number;
/**
* Is Active
* @default true
*/
is_active: boolean;
/** Calendar Event Id */
calendar_event_id?: string | null;
/** Calendar Metadata */
calendar_metadata?: {
[key: string]: unknown;
} | null;
/** Last Participant Left At */
last_participant_left_at?: string | null;
/**
* Grace Period Minutes
* @default 15
*/
grace_period_minutes: number;
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -844,6 +1103,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
/** Ics Last Sync */
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
};
/** RoomDetails */
RoomDetails: {
@@ -874,6 +1149,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
/** Ics Last Sync */
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1013,27 +1304,33 @@ export interface components {
/** UpdateRoom */
UpdateRoom: {
/** Name */
name: string;
name?: string | null;
/** Zulip Auto Post */
zulip_auto_post: boolean;
zulip_auto_post?: boolean | null;
/** Zulip Stream */
zulip_stream: string;
zulip_stream?: string | null;
/** Zulip Topic */
zulip_topic: string;
zulip_topic?: string | null;
/** Is Locked */
is_locked: boolean;
is_locked?: boolean | null;
/** Room Mode */
room_mode: string;
room_mode?: string | null;
/** Recording Type */
recording_type: string;
recording_type?: string | null;
/** Recording Trigger */
recording_trigger: string;
recording_trigger?: string | null;
/** Is Shared */
is_shared: boolean;
is_shared?: boolean | null;
/** Webhook Url */
webhook_url: string;
webhook_url?: string | null;
/** Webhook Secret */
webhook_secret: string;
webhook_secret?: string | null;
/** Ics Url */
ics_url?: string | null;
/** Ics Fetch Interval */
ics_fetch_interval?: number | null;
/** Ics Enabled */
ics_enabled?: boolean | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -1421,6 +1718,195 @@ export interface operations {
};
};
};
v1_rooms_sync_ics: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ICSSyncResult"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_ics_status: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ICSStatus"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_meetings: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarEventResponse"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_upcoming_meetings: {
parameters: {
query?: {
minutes_ahead?: number;
};
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarEventResponse"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_active_meetings: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meeting"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_join_meeting: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
meeting_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meeting"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcripts_list: {
parameters: {
query?: {

View File

@@ -3,10 +3,18 @@
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 type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import {
useRoomGetByName,
useRoomUpcomingMeetings,
useRoomActiveMeetings,
useRoomsCreateMeeting,
} from "../../lib/apiHooks";
import MeetingSelection from "../../[roomName]/MeetingSelection";
import { Meeting, Room } from "../../api";
type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["Room"];
interface RoomPageProps {
params: {
@@ -17,66 +25,26 @@ interface RoomPageProps {
export default function RoomPage({ params }: RoomPageProps) {
const { roomName } = params;
const router = useRouter();
const api = useApi();
const { data: session } = useSessionStatus();
const auth = useAuth();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [checkingMeetings, setCheckingMeetings] = useState(false);
// React Query hooks
const roomQuery = useRoomGetByName(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const createMeetingMutation = useRoomsCreateMeeting();
const isOwner = session?.user?.id === room?.user_id;
const room = roomQuery.data;
const activeMeetings = activeMeetingsQuery.data || [];
const upcomingMeetings = upcomingMeetingsQuery.data || [];
useEffect(() => {
if (!api) return;
const isLoading = roomQuery.isLoading;
const isCheckingMeetings =
(room?.ics_enabled &&
(activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading)) ||
createMeetingMutation.isPending;
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 isOwner =
auth.status === "authenticated" && auth.user?.id === room?.user_id;
const handleMeetingSelect = (meeting: Meeting) => {
// Navigate to the classic room page with the meeting
@@ -86,18 +54,46 @@ export default function RoomPage({ params }: RoomPageProps) {
};
const handleCreateUnscheduled = async () => {
if (!api) return;
try {
// Create a new unscheduled meeting
const meeting = await api.v1RoomsCreateMeeting({ roomName });
const meeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
handleMeetingSelect(meeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
if (loading || checkingMeetings) {
// Auto-navigate logic based on query results
useEffect(() => {
if (!room || isLoading || isCheckingMeetings) return;
if (room.ics_enabled) {
// If there's only one active meeting and no upcoming, auto-join
if (activeMeetings.length === 1 && upcomingMeetings.length === 0) {
handleMeetingSelect(activeMeetings[0]);
} else if (activeMeetings.length === 0 && upcomingMeetings.length === 0) {
// No meetings, create unscheduled
handleCreateUnscheduled();
}
// Otherwise, show selection UI (handled by render)
} else {
// ICS not enabled, use traditional flow
handleCreateUnscheduled();
}
}, [room, activeMeetings, upcomingMeetings, isLoading, isCheckingMeetings]);
// Handle room not found
useEffect(() => {
if (roomQuery.isError) {
router.push("/rooms");
}
}, [roomQuery.isError, router]);
if (isLoading || isCheckingMeetings) {
return (
<Box
minH="100vh"
@@ -106,9 +102,9 @@ export default function RoomPage({ params }: RoomPageProps) {
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<VStack gap={4}>
<Spinner size="xl" color="blue.500" />
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text>
<Text>{isLoading ? "Loading room..." : "Checking meetings..."}</Text>
</VStack>
</Box>
);

View File

@@ -6,17 +6,20 @@ import {
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";
import type { components } from "../../../reflector-api";
import {
useRoomUpcomingMeetings,
useRoomActiveMeetings,
useRoomJoinMeeting,
} from "../../../lib/apiHooks";
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface WaitingPageProps {
params: {
@@ -32,37 +35,41 @@ export default function WaitingPage({ params }: WaitingPageProps) {
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();
// Use React Query hooks
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const loading = upcomingMeetingsQuery.isLoading;
useEffect(() => {
if (!api || !eventId) return;
if (!eventId || !upcomingMeetingsQuery.data) return;
const fetchEvent = async () => {
try {
const events = await api.v1RoomsListUpcomingMeetings({
roomName,
minutesAhead: 60,
});
const targetEvent = upcomingMeetingsQuery.data.find(
(e) => e.id === eventId,
);
if (targetEvent) {
setEvent(targetEvent);
} else if (!upcomingMeetingsQuery.isLoading) {
// Event not found or already started
router.push(`/room/${roomName}`);
}
}, [
eventId,
upcomingMeetingsQuery.data,
upcomingMeetingsQuery.isLoading,
router,
roomName,
]);
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]);
// Handle query errors
useEffect(() => {
if (upcomingMeetingsQuery.error) {
console.error("Failed to fetch event:", upcomingMeetingsQuery.error);
router.push(`/room/${roomName}`);
}
}, [upcomingMeetingsQuery.error, router, roomName]);
useEffect(() => {
if (!event) return;
@@ -81,25 +88,25 @@ export default function WaitingPage({ params }: WaitingPageProps) {
};
const checkForActiveMeeting = async () => {
if (!api || checkingMeeting) return;
if (checkingMeeting) return;
setCheckingMeeting(true);
try {
// Check for active meetings
const activeMeetings = await api.v1RoomsListActiveMeetings({
roomName,
});
// Refetch active meetings to get latest data
const result = await activeMeetingsQuery.refetch();
if (!result.data) return;
// Find meeting for this calendar event
const calendarMeeting = activeMeetings.find(
const calendarMeeting = result.data.find(
(m) => m.calendar_event_id === eventId,
);
if (calendarMeeting) {
// Meeting is now active, join it
const meeting = await api.v1RoomsJoinMeeting({
roomName,
meetingId: calendarMeeting.id,
const meeting = await joinMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName, meeting_id: calendarMeeting.id },
},
});
// Navigate to the meeting room
@@ -128,7 +135,14 @@ export default function WaitingPage({ params }: WaitingPageProps) {
clearInterval(interval);
if (checkInterval) clearInterval(checkInterval);
};
}, [event, api, eventId, roomName, checkingMeeting]);
}, [
event,
eventId,
roomName,
checkingMeeting,
activeMeetingsQuery,
joinMeetingMutation,
]);
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
@@ -165,7 +179,7 @@ export default function WaitingPage({ params }: WaitingPageProps) {
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<VStack gap={4}>
<Spinner size="xl" color="blue.500" />
<Text>Loading meeting details...</Text>
</VStack>
@@ -182,12 +196,10 @@ export default function WaitingPage({ params }: WaitingPageProps) {
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<VStack gap={4}>
<Text fontSize="lg">Meeting not found</Text>
<Button
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
>
<Button onClick={() => router.push(`/room/${roomName}`)}>
<FaArrowLeft />
Back to Room
</Button>
</VStack>
@@ -203,75 +215,91 @@ export default function WaitingPage({ params }: WaitingPageProps) {
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" />
<Box
maxW="lg"
width="100%"
mx={4}
bg="white"
borderRadius="lg"
boxShadow="md"
p={6}
>
<VStack gap={6}>
<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>
<VStack gap={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}
<Box width="100%">
<Text
fontSize="4xl"
fontWeight="bold"
textAlign="center"
color="blue.600"
>
{formatTime(timeRemaining)}
</Text>
<Box
width="100%"
height="8px"
bg="gray.200"
borderRadius="full"
mt={4}
position="relative"
overflow="hidden"
>
<Box
width={`${getProgressValue()}%`}
height="100%"
bg="blue.500"
borderRadius="full"
transition="width 0.3s ease"
/>
</Box>
</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()}
{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>
)}
{checkingMeeting && (
<HStack spacing={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" color="blue.600">
Checking if meeting has started...
</Text>
</HStack>
)}
</VStack>
<VStack gap={3} width="100%">
<Text fontSize="sm" color="gray.500">
Scheduled for {new Date(event.start_time).toLocaleString()}
</Text>
<Button
variant="outline"
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
width="100%"
>
Back to Meeting Selection
</Button>
{checkingMeeting && (
<HStack gap={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" color="blue.600">
Checking if meeting has started...
</Text>
</HStack>
)}
</VStack>
</CardBody>
</Card>
<Button
variant="outline"
onClick={() => router.push(`/room/${roomName}`)}
width="100%"
>
<FaArrowLeft />
Back to Meeting Selection
</Button>
</VStack>
</Box>
</Box>
);
}