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

View File

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

View File

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

View File

@@ -7,23 +7,22 @@ import {
Text, Text,
Button, Button,
Spinner, Spinner,
Card,
CardBody,
CardHeader,
Badge, Badge,
Divider,
Icon, Icon,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useEffect, useState } from "react"; import React from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa"; import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { Meeting, CalendarEventResponse } from "../api"; import type { components } from "../reflector-api";
import useApi from "../lib/useApi"; import {
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomJoinMeeting,
} from "../lib/apiHooks";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
interface MeetingSelectionProps { interface MeetingSelectionProps {
roomName: string; roomName: string;
isOwner: boolean; isOwner: boolean;
@@ -63,61 +62,33 @@ export default function MeetingSelection({
onMeetingSelect, onMeetingSelect,
onCreateUnscheduled, onCreateUnscheduled,
}: MeetingSelectionProps) { }: 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(); const router = useRouter();
useEffect(() => { // Use React Query hooks for data fetching
if (!api) return; const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const fetchMeetings = async () => { const activeMeetings = activeMeetingsQuery.data || [];
try { const upcomingEvents = upcomingMeetingsQuery.data || [];
setLoading(true); const loading =
activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading;
// Fetch active meetings const error = activeMeetingsQuery.error || upcomingMeetingsQuery.error;
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) => { const handleJoinMeeting = async (meetingId: string) => {
if (!api) return;
try { try {
const meeting = await api.v1RoomsJoinMeeting({ const meeting = await joinMeetingMutation.mutateAsync({
roomName, params: {
meetingId, path: {
room_name: roomName,
meeting_id: meetingId,
},
},
}); });
onMeetingSelect(meeting); onMeetingSelect(meeting);
} catch (err) { } catch (err) {
console.error("Failed to join meeting:", 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) { if (error) {
return ( return (
<Alert status="error" borderRadius="md"> <Box
<AlertIcon /> p={4}
<AlertTitle>Error</AlertTitle> borderRadius="md"
<AlertDescription>{error}</AlertDescription> bg="red.50"
</Alert> 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 ( return (
<VStack spacing={6} align="stretch" p={6}> <VStack gap={6} align="stretch" p={6}>
<Box> <Box>
<Text fontSize="2xl" fontWeight="bold" mb={4}> <Text fontSize="2xl" fontWeight="bold" mb={4}>
Select a Meeting Select a Meeting
@@ -158,26 +136,34 @@ export default function MeetingSelection({
<Text fontSize="lg" fontWeight="semibold" mb={3}> <Text fontSize="lg" fontWeight="semibold" mb={3}>
Active Meetings Active Meetings
</Text> </Text>
<VStack spacing={3} mb={6}> <VStack gap={3} mb={6}>
{activeMeetings.map((meeting) => ( {activeMeetings.map((meeting) => (
<Card key={meeting.id} width="100%" variant="outline"> <Box
<CardBody> key={meeting.id}
width="100%"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
>
<HStack justify="space-between" align="start"> <HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}> <VStack align="start" gap={2} flex={1}>
<HStack> <HStack>
<Icon as={FaCalendarAlt} color="blue.500" /> <Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold"> <Text fontWeight="semibold">
{meeting.calendar_metadata?.title || "Meeting"} {(meeting.calendar_metadata as any)?.title ||
"Meeting"}
</Text> </Text>
</HStack> </HStack>
{isOwner && meeting.calendar_metadata?.description && ( {isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
{meeting.calendar_metadata.description} {(meeting.calendar_metadata as any).description}
</Text> </Text>
)} )}
<HStack spacing={4} fontSize="sm" color="gray.500"> <HStack gap={4} fontSize="sm" color="gray.500">
<HStack> <HStack>
<Icon as={FaUsers} /> <Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text> <Text>{meeting.num_clients} participants</Text>
@@ -190,9 +176,10 @@ export default function MeetingSelection({
</HStack> </HStack>
</HStack> </HStack>
{isOwner && meeting.calendar_metadata?.attendees && ( {isOwner &&
<HStack spacing={2} flexWrap="wrap"> (meeting.calendar_metadata as any)?.attendees && (
{meeting.calendar_metadata.attendees <HStack gap={2} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 3) .slice(0, 3)
.map((attendee: any, idx: number) => ( .map((attendee: any, idx: number) => (
<Badge <Badge
@@ -203,10 +190,12 @@ export default function MeetingSelection({
{attendee.name || attendee.email} {attendee.name || attendee.email}
</Badge> </Badge>
))} ))}
{meeting.calendar_metadata.attendees.length > 3 && ( {(meeting.calendar_metadata as any).attendees
.length > 3 && (
<Badge colorScheme="gray" fontSize="xs"> <Badge colorScheme="gray" fontSize="xs">
+ +
{meeting.calendar_metadata.attendees.length - 3}{" "} {(meeting.calendar_metadata as any).attendees
.length - 3}{" "}
more more
</Badge> </Badge>
)} )}
@@ -222,8 +211,7 @@ export default function MeetingSelection({
Join Now Join Now
</Button> </Button>
</HStack> </HStack>
</CardBody> </Box>
</Card>
))} ))}
</VStack> </VStack>
</> </>
@@ -235,17 +223,19 @@ export default function MeetingSelection({
<Text fontSize="lg" fontWeight="semibold" mb={3}> <Text fontSize="lg" fontWeight="semibold" mb={3}>
Upcoming Meetings Upcoming Meetings
</Text> </Text>
<VStack spacing={3} mb={6}> <VStack gap={3} mb={6}>
{upcomingEvents.map((event) => ( {upcomingEvents.map((event) => (
<Card <Box
key={event.id} key={event.id}
width="100%" width="100%"
variant="outline" border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
bg="gray.50" bg="gray.50"
> >
<CardBody>
<HStack justify="space-between" align="start"> <HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}> <VStack align="start" gap={2} flex={1}>
<HStack> <HStack>
<Icon as={FaCalendarAlt} color="orange.500" /> <Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold"> <Text fontWeight="semibold">
@@ -262,7 +252,7 @@ export default function MeetingSelection({
</Text> </Text>
)} )}
<HStack spacing={4} fontSize="sm" color="gray.500"> <HStack gap={4} fontSize="sm" color="gray.500">
<Text> <Text>
{formatDateTime(event.start_time)} -{" "} {formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)} {formatDateTime(event.end_time)}
@@ -270,7 +260,7 @@ export default function MeetingSelection({
</HStack> </HStack>
{isOwner && event.attendees && ( {isOwner && event.attendees && (
<HStack spacing={2} flexWrap="wrap"> <HStack gap={2} flexWrap="wrap">
{event.attendees {event.attendees
.slice(0, 3) .slice(0, 3)
.map((attendee: any, idx: number) => ( .map((attendee: any, idx: number) => (
@@ -300,35 +290,29 @@ export default function MeetingSelection({
Join Early Join Early
</Button> </Button>
</HStack> </HStack>
</CardBody> </Box>
</Card>
))} ))}
</VStack> </VStack>
</> </>
)} )}
<Divider my={6} /> <Box h="1px" bg="gray.200" my={6} />
{/* Create Unscheduled Meeting */} {/* Create Unscheduled Meeting */}
<Card width="100%" variant="filled" bg="gray.100"> <Box width="100%" bg="gray.100" borderRadius="md" p={4}>
<CardBody>
<HStack justify="space-between" align="center"> <HStack justify="space-between" align="center">
<VStack align="start" spacing={1}> <VStack align="start" gap={1}>
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text> <Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Create a new meeting room that's not on the calendar Create a new meeting room that's not on the calendar
</Text> </Text>
</VStack> </VStack>
<Button <Button colorScheme="green" onClick={onCreateUnscheduled}>
leftIcon={<FaPlus />} <FaPlus />
colorScheme="green"
onClick={onCreateUnscheduled}
>
Create Meeting Create Meeting
</Button> </Button>
</HStack> </HStack>
</CardBody> </Box>
</Card>
</Box> </Box>
</VStack> </VStack>
); );

View File

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

View File

@@ -47,7 +47,6 @@ const useRoomMeeting = (
const meeting = JSON.parse(storedMeeting); const meeting = JSON.parse(storedMeeting);
sessionStorage.removeItem(`meeting_${roomName}`); // Clean up sessionStorage.removeItem(`meeting_${roomName}`); // Clean up
setResponse(meeting); setResponse(meeting);
setLoading(false);
return; return;
} catch (e) { } catch (e) {
console.error("Failed to parse stored meeting:", 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; patch?: never;
trace?: 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": { "/v1/transcripts": {
parameters: { parameters: {
query?: never; query?: never;
@@ -505,6 +613,52 @@ export interface components {
*/ */
chunk: string; 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 */
CreateParticipant: { CreateParticipant: {
/** Speaker */ /** Speaker */
@@ -536,6 +690,18 @@ export interface components {
webhook_url: string; webhook_url: string;
/** Webhook Secret */ /** Webhook Secret */
webhook_secret: string; 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 */
CreateTranscript: { CreateTranscript: {
@@ -748,6 +914,51 @@ export interface components {
/** Detail */ /** Detail */
detail?: components["schemas"]["ValidationError"][]; 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 */
Meeting: { Meeting: {
/** Id */ /** Id */
@@ -768,12 +979,60 @@ export interface components {
* Format: date-time * Format: date-time
*/ */
end_date: string; 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 * Recording Type
* @default cloud * @default cloud
* @enum {string} * @enum {string}
*/ */
recording_type: "none" | "local" | "cloud"; 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 */
MeetingConsentRequest: { MeetingConsentRequest: {
@@ -844,6 +1103,22 @@ export interface components {
recording_trigger: string; recording_trigger: string;
/** Is Shared */ /** Is Shared */
is_shared: boolean; 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 */
RoomDetails: { RoomDetails: {
@@ -874,6 +1149,22 @@ export interface components {
recording_trigger: string; recording_trigger: string;
/** Is Shared */ /** Is Shared */
is_shared: boolean; 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 */
webhook_url: string | null; webhook_url: string | null;
/** Webhook Secret */ /** Webhook Secret */
@@ -1013,27 +1304,33 @@ export interface components {
/** UpdateRoom */ /** UpdateRoom */
UpdateRoom: { UpdateRoom: {
/** Name */ /** Name */
name: string; name?: string | null;
/** Zulip Auto Post */ /** Zulip Auto Post */
zulip_auto_post: boolean; zulip_auto_post?: boolean | null;
/** Zulip Stream */ /** Zulip Stream */
zulip_stream: string; zulip_stream?: string | null;
/** Zulip Topic */ /** Zulip Topic */
zulip_topic: string; zulip_topic?: string | null;
/** Is Locked */ /** Is Locked */
is_locked: boolean; is_locked?: boolean | null;
/** Room Mode */ /** Room Mode */
room_mode: string; room_mode?: string | null;
/** Recording Type */ /** Recording Type */
recording_type: string; recording_type?: string | null;
/** Recording Trigger */ /** Recording Trigger */
recording_trigger: string; recording_trigger?: string | null;
/** Is Shared */ /** Is Shared */
is_shared: boolean; is_shared?: boolean | null;
/** Webhook Url */ /** Webhook Url */
webhook_url: string; webhook_url?: string | null;
/** Webhook Secret */ /** 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 */
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: { v1_transcripts_list: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -3,10 +3,18 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Box, Spinner, VStack, Text } from "@chakra-ui/react"; import { Box, Spinner, VStack, Text } from "@chakra-ui/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import useApi from "../../lib/useApi"; import type { components } from "../../reflector-api";
import useSessionStatus from "../../lib/useSessionStatus"; import { useAuth } from "../../lib/AuthProvider";
import {
useRoomGetByName,
useRoomUpcomingMeetings,
useRoomActiveMeetings,
useRoomsCreateMeeting,
} from "../../lib/apiHooks";
import MeetingSelection from "../../[roomName]/MeetingSelection"; import MeetingSelection from "../../[roomName]/MeetingSelection";
import { Meeting, Room } from "../../api";
type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["Room"];
interface RoomPageProps { interface RoomPageProps {
params: { params: {
@@ -17,66 +25,26 @@ interface RoomPageProps {
export default function RoomPage({ params }: RoomPageProps) { export default function RoomPage({ params }: RoomPageProps) {
const { roomName } = params; const { roomName } = params;
const router = useRouter(); const router = useRouter();
const api = useApi(); const auth = useAuth();
const { data: session } = useSessionStatus();
const [room, setRoom] = useState<Room | null>(null); // React Query hooks
const [loading, setLoading] = useState(true); const roomQuery = useRoomGetByName(roomName);
const [checkingMeetings, setCheckingMeetings] = useState(false); 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(() => { const isLoading = roomQuery.isLoading;
if (!api) return; const isCheckingMeetings =
(room?.ics_enabled &&
(activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading)) ||
createMeetingMutation.isPending;
const fetchRoom = async () => { const isOwner =
try { auth.status === "authenticated" && auth.user?.id === room?.user_id;
// 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) => { const handleMeetingSelect = (meeting: Meeting) => {
// Navigate to the classic room page with the meeting // Navigate to the classic room page with the meeting
@@ -86,18 +54,46 @@ export default function RoomPage({ params }: RoomPageProps) {
}; };
const handleCreateUnscheduled = async () => { const handleCreateUnscheduled = async () => {
if (!api) return;
try { try {
// Create a new unscheduled meeting // Create a new unscheduled meeting
const meeting = await api.v1RoomsCreateMeeting({ roomName }); const meeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
handleMeetingSelect(meeting); handleMeetingSelect(meeting);
} catch (err) { } catch (err) {
console.error("Failed to create meeting:", 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 ( return (
<Box <Box
minH="100vh" minH="100vh"
@@ -106,9 +102,9 @@ export default function RoomPage({ params }: RoomPageProps) {
justifyContent="center" justifyContent="center"
bg="gray.50" bg="gray.50"
> >
<VStack spacing={4}> <VStack gap={4}>
<Spinner size="xl" color="blue.500" /> <Spinner size="xl" color="blue.500" />
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text> <Text>{isLoading ? "Loading room..." : "Checking meetings..."}</Text>
</VStack> </VStack>
</Box> </Box>
); );

View File

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