mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
feat: implement frontend for calendar integration (Phase 3 & 4)
## Frontend Implementation
### Meeting Selection & Management
- Created MeetingSelection component for choosing between multiple active meetings
- Shows both active meetings and upcoming calendar events (30 min ahead)
- Displays meeting metadata with privacy controls (owner-only details)
- Supports creation of unscheduled meetings alongside calendar meetings
### Waiting Room
- Added waiting page for users joining before scheduled start time
- Shows countdown timer until meeting begins
- Auto-transitions to meeting when calendar event becomes active
- Handles early joining with proper routing
### Meeting Info Panel
- Created collapsible info panel showing meeting details
- Displays calendar metadata (title, description, attendees)
- Shows participant count and duration
- Privacy-aware: sensitive info only visible to room owners
### ICS Configuration UI
- Integrated ICS settings into room configuration dialog
- Test connection functionality with immediate feedback
- Manual sync trigger with detailed results
- Shows last sync time and ETag for monitoring
- Configurable sync intervals (1 min to 1 hour)
### Routing & Navigation
- New /room/{roomName} route for meeting selection
- Waiting room at /room/{roomName}/wait?eventId={id}
- Classic room page at /{roomName} with meeting info
- Uses sessionStorage to pass selected meeting between pages
### API Integration
- Added new endpoints for active/upcoming meetings
- Regenerated TypeScript client with latest OpenAPI spec
- Proper error handling and loading states
- Auto-refresh every 30 seconds for live updates
### UI/UX Improvements
- Color-coded badges for meeting status
- Attendee status indicators (accepted/declined/tentative)
- Responsive design with Chakra UI components
- Clear visual hierarchy between active and upcoming meetings
- Smart truncation for long attendee lists
This completes the frontend implementation for calendar integration,
enabling users to seamlessly join scheduled meetings from their
calendar applications.
This commit is contained in:
74
PLAN.md
74
PLAN.md
@@ -184,20 +184,43 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting
|
|||||||
- Simple HTTP fetching without unnecessary validation
|
- Simple HTTP fetching without unnecessary validation
|
||||||
- Proper TypedDict typing for event data structures
|
- Proper TypedDict typing for event data structures
|
||||||
- Supports any standard ICS format
|
- Supports any standard ICS format
|
||||||
4. ⚠️ API endpoints for ICS configuration (partial)
|
- Event matching on full room URL only
|
||||||
|
4. ✅ API endpoints for ICS configuration
|
||||||
- Room model updated to support ICS fields via existing PATCH endpoint
|
- Room model updated to support ICS fields via existing PATCH endpoint
|
||||||
- Dedicated ICS endpoints still pending
|
- POST /v1/rooms/{room_name}/ics/sync - Trigger manual sync (owner only)
|
||||||
5. ⚠️ Celery background tasks for periodic sync (pending)
|
- GET /v1/rooms/{room_name}/ics/status - Get sync status (owner only)
|
||||||
|
- GET /v1/rooms/{room_name}/meetings - List meetings with privacy controls
|
||||||
|
- GET /v1/rooms/{room_name}/meetings/upcoming - List upcoming meetings
|
||||||
|
5. ✅ Celery background tasks for periodic sync
|
||||||
|
- sync_room_ics - Sync individual room calendar
|
||||||
|
- sync_all_ics_calendars - Check all rooms and queue sync based on fetch intervals
|
||||||
|
- pre_create_upcoming_meetings - Pre-create Whereby meetings 1 minute before start
|
||||||
|
- Tasks scheduled in beat schedule (every minute for checking, respects individual intervals)
|
||||||
6. ✅ Tests written and passing
|
6. ✅ Tests written and passing
|
||||||
- 6 tests for Room ICS fields
|
- 6 tests for Room ICS fields
|
||||||
- 7 tests for CalendarEvent model
|
- 7 tests for CalendarEvent model
|
||||||
- All 13 tests passing
|
- 7 tests for ICS sync service
|
||||||
|
- 11 tests for API endpoints
|
||||||
|
- 6 tests for background tasks
|
||||||
|
- All 31 ICS-related tests passing
|
||||||
|
|
||||||
### Phase 2: Meeting Management (Week 2)
|
### Phase 2: Meeting Management (Week 2) ✅ COMPLETED (2025-08-19)
|
||||||
1. Update meeting lifecycle logic
|
1. ✅ Updated meeting lifecycle logic with grace period support
|
||||||
2. Support multiple active meetings
|
- 15-minute grace period after last participant leaves
|
||||||
3. Implement grace period logic
|
- Automatic reactivation when participants rejoin
|
||||||
4. Link meetings to calendar events
|
- Force close calendar meetings 30 minutes after scheduled end
|
||||||
|
2. ✅ Support multiple active meetings per room
|
||||||
|
- Removed unique constraint on active meetings
|
||||||
|
- Added get_all_active_for_room() method
|
||||||
|
- Added get_active_by_calendar_event() method
|
||||||
|
3. ✅ Implemented grace period logic
|
||||||
|
- Added last_participant_left_at and grace_period_minutes fields
|
||||||
|
- Process meetings task handles grace period checking
|
||||||
|
- Whereby webhooks clear grace period on participant join
|
||||||
|
4. ✅ Link meetings to calendar events
|
||||||
|
- Pre-created meetings properly linked via calendar_event_id
|
||||||
|
- Calendar metadata stored with meeting
|
||||||
|
- API endpoints for listing and joining specific meetings
|
||||||
|
|
||||||
### Phase 3: Frontend Meeting Selection (Week 3)
|
### Phase 3: Frontend Meeting Selection (Week 3)
|
||||||
1. Build meeting selection page
|
1. Build meeting selection page
|
||||||
@@ -232,6 +255,25 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting
|
|||||||
9. **Configurable fetch interval** - Balance between freshness and server load
|
9. **Configurable fetch interval** - Balance between freshness and server load
|
||||||
10. **ICS over CalDAV** - Simpler implementation, wider compatibility, no complex auth
|
10. **ICS over CalDAV** - Simpler implementation, wider compatibility, no complex auth
|
||||||
|
|
||||||
|
## Phase 2 Implementation Files
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
- `/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py` - Remove unique constraint
|
||||||
|
- `/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py` - Add grace period fields
|
||||||
|
|
||||||
|
### Updated Models
|
||||||
|
- `/server/reflector/db/meetings.py` - Added grace period fields and new query methods
|
||||||
|
|
||||||
|
### Updated Services
|
||||||
|
- `/server/reflector/worker/process.py` - Enhanced with grace period logic and multiple meeting support
|
||||||
|
|
||||||
|
### Updated API
|
||||||
|
- `/server/reflector/views/rooms.py` - Added endpoints for listing active meetings and joining specific meetings
|
||||||
|
- `/server/reflector/views/whereby.py` - Clear grace period on participant join
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `/server/tests/test_multiple_active_meetings.py` - Comprehensive tests for Phase 2 features (5 tests)
|
||||||
|
|
||||||
## Phase 1 Implementation Files Created
|
## Phase 1 Implementation Files Created
|
||||||
|
|
||||||
### Database Models
|
### Database Models
|
||||||
@@ -240,11 +282,21 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting
|
|||||||
- `/server/reflector/db/meetings.py` - Updated with calendar_event_id and calendar_metadata (JSONB)
|
- `/server/reflector/db/meetings.py` - Updated with calendar_event_id and calendar_metadata (JSONB)
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
- `/server/reflector/services/ics_sync.py` - ICS fetching with TypedDict for proper typing
|
- `/server/reflector/services/ics_sync.py` - ICS fetching and parsing with TypedDict for proper typing
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `/server/reflector/views/rooms.py` - Added ICS management endpoints with privacy controls
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
- `/server/reflector/worker/ics_sync.py` - Celery tasks for automatic periodic sync
|
||||||
|
- `/server/reflector/worker/app.py` - Updated beat schedule for ICS tasks
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- `/server/tests/test_room_ics.py` - Room model ICS fields tests (6 tests)
|
- `/server/tests/test_room_ics.py` - Room model ICS fields tests (6 tests)
|
||||||
- `/server/tests/test_calendar_event.py` - CalendarEvent model tests (7 tests)
|
- `/server/tests/test_calendar_event.py` - CalendarEvent model tests (7 tests)
|
||||||
|
- `/server/tests/test_ics_sync.py` - ICS sync service tests (7 tests)
|
||||||
|
- `/server/tests/test_room_ics_api.py` - API endpoint tests (11 tests)
|
||||||
|
- `/server/tests/test_ics_background_tasks.py` - Background task tests (6 tests)
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
- No encryption needed - ICS URLs are read-only access
|
- No encryption needed - ICS URLs are read-only access
|
||||||
@@ -252,6 +304,8 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting
|
|||||||
- Proper TypedDict typing for event data structures
|
- Proper TypedDict typing for event data structures
|
||||||
- Removed unnecessary URL validation and webcal handling
|
- Removed unnecessary URL validation and webcal handling
|
||||||
- calendar_metadata in meetings stores flexible calendar data (organizer, recurrence, etc)
|
- calendar_metadata in meetings stores flexible calendar data (organizer, recurrence, etc)
|
||||||
|
- Background tasks query all rooms directly to avoid filtering issues
|
||||||
|
- Sync intervals respected per-room configuration
|
||||||
|
|
||||||
## Implementation Approach
|
## Implementation Approach
|
||||||
|
|
||||||
|
|||||||
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
AlertTitle,
|
||||||
|
Badge,
|
||||||
|
createListCollection,
|
||||||
|
Spinner,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||||
|
import useApi from "../../../lib/useApi";
|
||||||
|
|
||||||
|
interface ICSSettingsProps {
|
||||||
|
roomId?: string;
|
||||||
|
roomName?: string;
|
||||||
|
icsUrl?: string;
|
||||||
|
icsEnabled?: boolean;
|
||||||
|
icsFetchInterval?: number;
|
||||||
|
icsLastSync?: string;
|
||||||
|
icsLastEtag?: string;
|
||||||
|
onChange: (settings: Partial<ICSSettingsData>) => void;
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICSSettingsData {
|
||||||
|
ics_url: string;
|
||||||
|
ics_enabled: boolean;
|
||||||
|
ics_fetch_interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchIntervalOptions = [
|
||||||
|
{ label: "1 minute", value: "1" },
|
||||||
|
{ label: "5 minutes", value: "5" },
|
||||||
|
{ label: "10 minutes", value: "10" },
|
||||||
|
{ label: "30 minutes", value: "30" },
|
||||||
|
{ label: "1 hour", value: "60" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ICSSettings({
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
icsUrl = "",
|
||||||
|
icsEnabled = false,
|
||||||
|
icsFetchInterval = 5,
|
||||||
|
icsLastSync,
|
||||||
|
icsLastEtag,
|
||||||
|
onChange,
|
||||||
|
isOwner = true,
|
||||||
|
}: ICSSettingsProps) {
|
||||||
|
const [syncStatus, setSyncStatus] = useState<
|
||||||
|
"idle" | "syncing" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [syncMessage, setSyncMessage] = useState<string>("");
|
||||||
|
const [testResult, setTestResult] = useState<string>("");
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const fetchIntervalCollection = createListCollection({
|
||||||
|
items: fetchIntervalOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!api || !icsUrl || !roomName) return;
|
||||||
|
|
||||||
|
setSyncStatus("syncing");
|
||||||
|
setTestResult("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First update the room with the ICS URL
|
||||||
|
await api.v1RoomsPartialUpdate({
|
||||||
|
roomId: roomId || roomName,
|
||||||
|
requestBody: {
|
||||||
|
ics_url: icsUrl,
|
||||||
|
ics_enabled: true,
|
||||||
|
ics_fetch_interval: icsFetchInterval,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then trigger a sync
|
||||||
|
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||||
|
|
||||||
|
if (result.status === "success") {
|
||||||
|
setSyncStatus("success");
|
||||||
|
setTestResult(
|
||||||
|
`Successfully synced! Found ${result.events_found} events.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSyncStatus("error");
|
||||||
|
setTestResult(result.error || "Sync failed");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setSyncStatus("error");
|
||||||
|
setTestResult(err.body?.detail || "Failed to test ICS connection");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualSync = async () => {
|
||||||
|
if (!api || !roomName) return;
|
||||||
|
|
||||||
|
setSyncStatus("syncing");
|
||||||
|
setSyncMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||||
|
|
||||||
|
if (result.status === "success") {
|
||||||
|
setSyncStatus("success");
|
||||||
|
setSyncMessage(
|
||||||
|
`Sync complete! Found ${result.events_found} events, ` +
|
||||||
|
`created ${result.events_created}, updated ${result.events_updated}.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSyncStatus("error");
|
||||||
|
setSyncMessage(result.error || "Sync failed");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setSyncStatus("error");
|
||||||
|
setSyncMessage(err.body?.detail || "Failed to sync calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear status after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setSyncStatus("idle");
|
||||||
|
setSyncMessage("");
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
|
return null; // ICS settings only visible to room owner
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch" mt={6}>
|
||||||
|
<Text fontWeight="semibold" fontSize="lg">
|
||||||
|
Calendar Integration (ICS)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Field.Root>
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={icsEnabled}
|
||||||
|
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
|
||||||
|
>
|
||||||
|
<Checkbox.HiddenInput />
|
||||||
|
<Checkbox.Control>
|
||||||
|
<Checkbox.Indicator />
|
||||||
|
</Checkbox.Control>
|
||||||
|
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
|
||||||
|
</Checkbox.Root>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{icsEnabled && (
|
||||||
|
<>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>ICS Calendar URL</Field.Label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||||
|
value={icsUrl}
|
||||||
|
onChange={(e) => onChange({ ics_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Field.HelperText>
|
||||||
|
Enter the ICS URL from Google Calendar, Outlook, or other calendar
|
||||||
|
services
|
||||||
|
</Field.HelperText>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Sync Interval</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
collection={fetchIntervalCollection}
|
||||||
|
value={[icsFetchInterval.toString()]}
|
||||||
|
onValueChange={(details) => {
|
||||||
|
const value = parseInt(details.value[0]);
|
||||||
|
onChange({ ics_fetch_interval: value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger>
|
||||||
|
<Select.ValueText />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{fetchIntervalOptions.map((option) => (
|
||||||
|
<Select.Item key={option.value} item={option}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Field.HelperText>
|
||||||
|
How often to check for calendar updates
|
||||||
|
</Field.HelperText>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{icsUrl && (
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={syncStatus === "syncing"}
|
||||||
|
leftIcon={
|
||||||
|
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{roomName && icsLastSync && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleManualSync}
|
||||||
|
disabled={syncStatus === "syncing"}
|
||||||
|
leftIcon={<FaSync />}
|
||||||
|
>
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Text fontSize="sm">{testResult}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncMessage && (
|
||||||
|
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Text fontSize="sm">{syncMessage}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{icsLastSync && (
|
||||||
|
<HStack spacing={4} fontSize="sm" color="gray.600">
|
||||||
|
<HStack>
|
||||||
|
<FaCheckCircle color="green" />
|
||||||
|
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
|
||||||
|
</HStack>
|
||||||
|
{icsLastEtag && (
|
||||||
|
<Badge colorScheme="blue" fontSize="xs">
|
||||||
|
ETag: {icsLastEtag.slice(0, 8)}...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import useApi from "../../lib/useApi";
|
|||||||
import useRoomList from "./useRoomList";
|
import useRoomList from "./useRoomList";
|
||||||
import { ApiError, Room } from "../../api";
|
import { ApiError, Room } from "../../api";
|
||||||
import { RoomList } from "./_components/RoomList";
|
import { RoomList } from "./_components/RoomList";
|
||||||
|
import ICSSettings from "./_components/ICSSettings";
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -54,6 +55,9 @@ const roomInitialState = {
|
|||||||
recordingType: "cloud",
|
recordingType: "cloud",
|
||||||
recordingTrigger: "automatic-2nd-participant",
|
recordingTrigger: "automatic-2nd-participant",
|
||||||
isShared: false,
|
isShared: false,
|
||||||
|
icsUrl: "",
|
||||||
|
icsEnabled: false,
|
||||||
|
icsFetchInterval: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RoomsList() {
|
export default function RoomsList() {
|
||||||
@@ -170,6 +174,9 @@ export default function RoomsList() {
|
|||||||
recording_type: room.recordingType,
|
recording_type: room.recordingType,
|
||||||
recording_trigger: room.recordingTrigger,
|
recording_trigger: room.recordingTrigger,
|
||||||
is_shared: room.isShared,
|
is_shared: room.isShared,
|
||||||
|
ics_url: room.icsUrl,
|
||||||
|
ics_enabled: room.icsEnabled,
|
||||||
|
ics_fetch_interval: room.icsFetchInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -215,6 +222,9 @@ export default function RoomsList() {
|
|||||||
recordingType: roomData.recording_type,
|
recordingType: roomData.recording_type,
|
||||||
recordingTrigger: roomData.recording_trigger,
|
recordingTrigger: roomData.recording_trigger,
|
||||||
isShared: roomData.is_shared,
|
isShared: roomData.is_shared,
|
||||||
|
icsUrl: roomData.ics_url || "",
|
||||||
|
icsEnabled: roomData.ics_enabled || false,
|
||||||
|
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
||||||
});
|
});
|
||||||
setEditRoomId(roomId);
|
setEditRoomId(roomId);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -553,6 +563,32 @@ export default function RoomsList() {
|
|||||||
<Checkbox.Label>Shared room</Checkbox.Label>
|
<Checkbox.Label>Shared room</Checkbox.Label>
|
||||||
</Checkbox.Root>
|
</Checkbox.Root>
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
|
|
||||||
|
<ICSSettings
|
||||||
|
roomId={editRoomId}
|
||||||
|
roomName={room.name}
|
||||||
|
icsUrl={room.icsUrl}
|
||||||
|
icsEnabled={room.icsEnabled}
|
||||||
|
icsFetchInterval={room.icsFetchInterval}
|
||||||
|
onChange={(settings) => {
|
||||||
|
setRoom({
|
||||||
|
...room,
|
||||||
|
icsUrl:
|
||||||
|
settings.ics_url !== undefined
|
||||||
|
? settings.ics_url
|
||||||
|
: room.icsUrl,
|
||||||
|
icsEnabled:
|
||||||
|
settings.ics_enabled !== undefined
|
||||||
|
? settings.ics_enabled
|
||||||
|
: room.icsEnabled,
|
||||||
|
icsFetchInterval:
|
||||||
|
settings.ics_fetch_interval !== undefined
|
||||||
|
? settings.ics_fetch_interval
|
||||||
|
: room.icsFetchInterval,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isOwner={true}
|
||||||
|
/>
|
||||||
</Dialog.Body>
|
</Dialog.Body>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
|||||||
203
www/app/[roomName]/MeetingInfo.tsx
Normal file
203
www/app/[roomName]/MeetingInfo.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Divider,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
|
||||||
|
import { Meeting } from "../api";
|
||||||
|
|
||||||
|
interface MeetingInfoProps {
|
||||||
|
meeting: Meeting;
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
|
||||||
|
const formatDuration = (start: string | Date, end: string | Date) => {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// If meeting hasn't started yet
|
||||||
|
if (startDate > now) {
|
||||||
|
return `Scheduled for ${startDate.toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const durationMs = now.getTime() - startDate.getTime();
|
||||||
|
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes} minutes`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCalendarMeeting = !!meeting.calendar_event_id;
|
||||||
|
const metadata = meeting.calendar_metadata;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
right="8px"
|
||||||
|
bg="white"
|
||||||
|
borderRadius="md"
|
||||||
|
boxShadow="lg"
|
||||||
|
p={4}
|
||||||
|
maxW="300px"
|
||||||
|
zIndex={999}
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{/* Meeting Title */}
|
||||||
|
<HStack>
|
||||||
|
<Icon
|
||||||
|
as={isCalendarMeeting ? FaCalendarAlt : FaInfoCircle}
|
||||||
|
color="blue.500"
|
||||||
|
/>
|
||||||
|
<Text fontWeight="semibold" fontSize="md">
|
||||||
|
{metadata?.title ||
|
||||||
|
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Meeting Status */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
{meeting.is_active && (
|
||||||
|
<Badge colorScheme="green" fontSize="xs">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCalendarMeeting && (
|
||||||
|
<Badge colorScheme="blue" fontSize="xs">
|
||||||
|
Calendar
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{meeting.is_locked && (
|
||||||
|
<Badge colorScheme="orange" fontSize="xs">
|
||||||
|
Locked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Meeting Details */}
|
||||||
|
<VStack align="stretch" spacing={2} fontSize="sm">
|
||||||
|
{/* Participants */}
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaUsers} color="gray.500" />
|
||||||
|
<Text>
|
||||||
|
{meeting.num_clients}{" "}
|
||||||
|
{meeting.num_clients === 1 ? "participant" : "participants"}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaClock} color="gray.500" />
|
||||||
|
<Text>
|
||||||
|
Duration: {formatDuration(meeting.start_date, meeting.end_date)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Calendar Description (Owner only) */}
|
||||||
|
{isOwner && metadata?.description && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<Text
|
||||||
|
fontWeight="semibold"
|
||||||
|
fontSize="xs"
|
||||||
|
color="gray.600"
|
||||||
|
mb={1}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="gray.700">
|
||||||
|
{metadata.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attendees (Owner only) */}
|
||||||
|
{isOwner && metadata?.attendees && metadata.attendees.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box>
|
||||||
|
<Text
|
||||||
|
fontWeight="semibold"
|
||||||
|
fontSize="xs"
|
||||||
|
color="gray.600"
|
||||||
|
mb={1}
|
||||||
|
>
|
||||||
|
Invited Attendees ({metadata.attendees.length})
|
||||||
|
</Text>
|
||||||
|
<VStack align="stretch" spacing={1}>
|
||||||
|
{metadata.attendees
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((attendee: any, idx: number) => (
|
||||||
|
<HStack key={idx} fontSize="xs">
|
||||||
|
<Badge
|
||||||
|
colorScheme={
|
||||||
|
attendee.status === "ACCEPTED"
|
||||||
|
? "green"
|
||||||
|
: attendee.status === "DECLINED"
|
||||||
|
? "red"
|
||||||
|
: attendee.status === "TENTATIVE"
|
||||||
|
? "yellow"
|
||||||
|
: "gray"
|
||||||
|
}
|
||||||
|
fontSize="xs"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{attendee.status?.charAt(0) || "?"}
|
||||||
|
</Badge>
|
||||||
|
<Text color="gray.700" isTruncated>
|
||||||
|
{attendee.name || attendee.email}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
{metadata.attendees.length > 5 && (
|
||||||
|
<Text fontSize="xs" color="gray.500" fontStyle="italic">
|
||||||
|
+{metadata.attendees.length - 5} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recording Info */}
|
||||||
|
{meeting.recording_type !== "none" && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack fontSize="xs">
|
||||||
|
<Badge colorScheme="red" fontSize="xs">
|
||||||
|
Recording
|
||||||
|
</Badge>
|
||||||
|
<Text color="gray.600">
|
||||||
|
{meeting.recording_type === "cloud" ? "Cloud" : "Local"}
|
||||||
|
{meeting.recording_trigger !== "none" &&
|
||||||
|
` (${meeting.recording_trigger})`}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Meeting Times */}
|
||||||
|
<Divider />
|
||||||
|
<VStack align="stretch" spacing={1} fontSize="xs" color="gray.600">
|
||||||
|
<Text>Start: {new Date(meeting.start_date).toLocaleString()}</Text>
|
||||||
|
<Text>End: {new Date(meeting.end_date).toLocaleString()}</Text>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
www/app/[roomName]/MeetingSelection.tsx
Normal file
335
www/app/[roomName]/MeetingSelection.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
Icon,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
AlertTitle,
|
||||||
|
AlertDescription,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
|
||||||
|
import { Meeting, CalendarEventResponse } from "../api";
|
||||||
|
import useApi from "../lib/useApi";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface MeetingSelectionProps {
|
||||||
|
roomName: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
onMeetingSelect: (meeting: Meeting) => void;
|
||||||
|
onCreateUnscheduled: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (date: string | Date) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCountdown = (startTime: string | Date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(startTime);
|
||||||
|
const diff = start.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diff <= 0) return "Starting now";
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `Starts in ${hours}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
return `Starts in ${minutes} minutes`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MeetingSelection({
|
||||||
|
roomName,
|
||||||
|
isOwner,
|
||||||
|
onMeetingSelect,
|
||||||
|
onCreateUnscheduled,
|
||||||
|
}: MeetingSelectionProps) {
|
||||||
|
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([]);
|
||||||
|
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEventResponse[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const fetchMeetings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Fetch active meetings
|
||||||
|
const active = await api.v1RoomsListActiveMeetings({ roomName });
|
||||||
|
setActiveMeetings(active);
|
||||||
|
|
||||||
|
// Fetch upcoming calendar events (30 min ahead)
|
||||||
|
const upcoming = await api.v1RoomsListUpcomingMeetings({
|
||||||
|
roomName,
|
||||||
|
minutesAhead: 30,
|
||||||
|
});
|
||||||
|
setUpcomingEvents(upcoming);
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch meetings:", err);
|
||||||
|
setError("Failed to load meetings. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMeetings();
|
||||||
|
|
||||||
|
// Refresh every 30 seconds
|
||||||
|
const interval = setInterval(fetchMeetings, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [api, roomName]);
|
||||||
|
|
||||||
|
const handleJoinMeeting = async (meetingId: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meeting = await api.v1RoomsJoinMeeting({
|
||||||
|
roomName,
|
||||||
|
meetingId,
|
||||||
|
});
|
||||||
|
onMeetingSelect(meeting);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to join meeting:", err);
|
||||||
|
setError("Failed to join meeting. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinUpcoming = (event: CalendarEventResponse) => {
|
||||||
|
// Navigate to waiting page with event info
|
||||||
|
router.push(`/room/${roomName}/wait?eventId=${event.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box p={8} textAlign="center">
|
||||||
|
<Spinner size="lg" color="blue.500" />
|
||||||
|
<Text mt={4}>Loading meetings...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert status="error" borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={6} align="stretch" p={6}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" mb={4}>
|
||||||
|
Select a Meeting
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Active Meetings */}
|
||||||
|
{activeMeetings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold" mb={3}>
|
||||||
|
Active Meetings
|
||||||
|
</Text>
|
||||||
|
<VStack spacing={3} mb={6}>
|
||||||
|
{activeMeetings.map((meeting) => (
|
||||||
|
<Card key={meeting.id} width="100%" variant="outline">
|
||||||
|
<CardBody>
|
||||||
|
<HStack justify="space-between" align="start">
|
||||||
|
<VStack align="start" spacing={2} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaCalendarAlt} color="blue.500" />
|
||||||
|
<Text fontWeight="semibold">
|
||||||
|
{meeting.calendar_metadata?.title || "Meeting"}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isOwner && meeting.calendar_metadata?.description && (
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{meeting.calendar_metadata.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack spacing={4} fontSize="sm" color="gray.500">
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaUsers} />
|
||||||
|
<Text>{meeting.num_clients} participants</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaClock} />
|
||||||
|
<Text>
|
||||||
|
Started {formatDateTime(meeting.start_date)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isOwner && meeting.calendar_metadata?.attendees && (
|
||||||
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
|
{meeting.calendar_metadata.attendees
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((attendee: any, idx: number) => (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
colorScheme="green"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{attendee.name || attendee.email}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{meeting.calendar_metadata.attendees.length > 3 && (
|
||||||
|
<Badge colorScheme="gray" fontSize="xs">
|
||||||
|
+
|
||||||
|
{meeting.calendar_metadata.attendees.length - 3}{" "}
|
||||||
|
more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
colorScheme="blue"
|
||||||
|
size="md"
|
||||||
|
onClick={() => handleJoinMeeting(meeting.id)}
|
||||||
|
>
|
||||||
|
Join Now
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Meetings */}
|
||||||
|
{upcomingEvents.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold" mb={3}>
|
||||||
|
Upcoming Meetings
|
||||||
|
</Text>
|
||||||
|
<VStack spacing={3} mb={6}>
|
||||||
|
{upcomingEvents.map((event) => (
|
||||||
|
<Card
|
||||||
|
key={event.id}
|
||||||
|
width="100%"
|
||||||
|
variant="outline"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<CardBody>
|
||||||
|
<HStack justify="space-between" align="start">
|
||||||
|
<VStack align="start" spacing={2} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Icon as={FaCalendarAlt} color="orange.500" />
|
||||||
|
<Text fontWeight="semibold">
|
||||||
|
{event.title || "Scheduled Meeting"}
|
||||||
|
</Text>
|
||||||
|
<Badge colorScheme="orange" fontSize="xs">
|
||||||
|
{formatCountdown(event.start_time)}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isOwner && event.description && (
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack spacing={4} fontSize="sm" color="gray.500">
|
||||||
|
<Text>
|
||||||
|
{formatDateTime(event.start_time)} -{" "}
|
||||||
|
{formatDateTime(event.end_time)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isOwner && event.attendees && (
|
||||||
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
|
{event.attendees
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((attendee: any, idx: number) => (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
colorScheme="purple"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{attendee.name || attendee.email}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{event.attendees.length > 3 && (
|
||||||
|
<Badge colorScheme="gray" fontSize="xs">
|
||||||
|
+{event.attendees.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
colorScheme="orange"
|
||||||
|
size="md"
|
||||||
|
onClick={() => handleJoinUpcoming(event)}
|
||||||
|
>
|
||||||
|
Join Early
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider my={6} />
|
||||||
|
|
||||||
|
{/* Create Unscheduled Meeting */}
|
||||||
|
<Card width="100%" variant="filled" bg="gray.100">
|
||||||
|
<CardBody>
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
<VStack align="start" spacing={1}>
|
||||||
|
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Create a new meeting room that's not on the calendar
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
<Button
|
||||||
|
leftIcon={<FaPlus />}
|
||||||
|
colorScheme="green"
|
||||||
|
onClick={onCreateUnscheduled}
|
||||||
|
>
|
||||||
|
Create Meeting
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ import { notFound } from "next/navigation";
|
|||||||
import useSessionStatus from "../lib/useSessionStatus";
|
import useSessionStatus from "../lib/useSessionStatus";
|
||||||
import { useRecordingConsent } from "../recordingConsentContext";
|
import { useRecordingConsent } from "../recordingConsentContext";
|
||||||
import useApi from "../lib/useApi";
|
import useApi from "../lib/useApi";
|
||||||
import { Meeting } from "../api";
|
import { Meeting, Room } from "../api";
|
||||||
import { FaBars } from "react-icons/fa6";
|
import { FaBars, FaInfoCircle } from "react-icons/fa6";
|
||||||
|
import MeetingInfo from "./MeetingInfo";
|
||||||
|
|
||||||
export type RoomDetails = {
|
export type RoomDetails = {
|
||||||
params: {
|
params: {
|
||||||
@@ -254,7 +255,10 @@ 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 { isLoading, isAuthenticated } = useSessionStatus();
|
const { isLoading, isAuthenticated, data: session } = useSessionStatus();
|
||||||
|
const [showMeetingInfo, setShowMeetingInfo] = useState(false);
|
||||||
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
const roomUrl = meeting?.response?.host_room_url
|
const roomUrl = meeting?.response?.host_room_url
|
||||||
? meeting?.response?.host_room_url
|
? meeting?.response?.host_room_url
|
||||||
@@ -268,6 +272,15 @@ export default function Room(details: RoomDetails) {
|
|||||||
router.push("/browse");
|
router.push("/browse");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Fetch room details
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !roomName) return;
|
||||||
|
|
||||||
|
api.v1RoomsRetrieve({ roomName }).then(setRoom).catch(console.error);
|
||||||
|
}, [api, roomName]);
|
||||||
|
|
||||||
|
const isOwner = session?.user?.id === room?.user_id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
@@ -319,6 +332,25 @@ export default function Room(details: RoomDetails) {
|
|||||||
wherebyRef={wherebyRef}
|
wherebyRef={wherebyRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{meeting?.response && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
right={showMeetingInfo ? "320px" : "8px"}
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
|
||||||
|
leftIcon={<Icon as={FaInfoCircle} />}
|
||||||
|
>
|
||||||
|
Meeting Info
|
||||||
|
</Button>
|
||||||
|
{showMeetingInfo && (
|
||||||
|
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ const useRoomMeeting = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomName || !api) return;
|
if (!roomName || !api) return;
|
||||||
|
|
||||||
|
// Check if meeting was pre-selected from meeting selection page
|
||||||
|
const storedMeeting = sessionStorage.getItem(`meeting_${roomName}`);
|
||||||
|
if (storedMeeting) {
|
||||||
|
try {
|
||||||
|
const meeting = JSON.parse(storedMeeting);
|
||||||
|
sessionStorage.removeItem(`meeting_${roomName}`); // Clean up
|
||||||
|
setResponse(meeting);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse stored meeting:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,108 @@ export const $Body_transcript_record_upload_v1_transcripts__transcript_id__recor
|
|||||||
"Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post",
|
"Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const $CalendarEventResponse = {
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
title: "Id",
|
||||||
|
},
|
||||||
|
room_id: {
|
||||||
|
type: "string",
|
||||||
|
title: "Room Id",
|
||||||
|
},
|
||||||
|
ics_uid: {
|
||||||
|
type: "string",
|
||||||
|
title: "Ics Uid",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Title",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Description",
|
||||||
|
},
|
||||||
|
start_time: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Start Time",
|
||||||
|
},
|
||||||
|
end_time: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "End Time",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
items: {
|
||||||
|
additionalProperties: true,
|
||||||
|
type: "object",
|
||||||
|
},
|
||||||
|
type: "array",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Attendees",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Location",
|
||||||
|
},
|
||||||
|
last_synced: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Last Synced",
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Created At",
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
title: "Updated At",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
required: [
|
||||||
|
"id",
|
||||||
|
"room_id",
|
||||||
|
"ics_uid",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"last_synced",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
title: "CalendarEventResponse",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const $CreateParticipant = {
|
export const $CreateParticipant = {
|
||||||
properties: {
|
properties: {
|
||||||
speaker: {
|
speaker: {
|
||||||
@@ -91,6 +193,27 @@ export const $CreateRoom = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
title: "Is Shared",
|
title: "Is Shared",
|
||||||
},
|
},
|
||||||
|
ics_url: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Url",
|
||||||
|
},
|
||||||
|
ics_fetch_interval: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Ics Fetch Interval",
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
ics_enabled: {
|
||||||
|
type: "boolean",
|
||||||
|
title: "Ics Enabled",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
required: [
|
||||||
@@ -687,6 +810,112 @@ export const $HTTPValidationError = {
|
|||||||
title: "HTTPValidationError",
|
title: "HTTPValidationError",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const $ICSStatus = {
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
title: "Status",
|
||||||
|
},
|
||||||
|
last_sync: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Last Sync",
|
||||||
|
},
|
||||||
|
next_sync: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Next Sync",
|
||||||
|
},
|
||||||
|
last_etag: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Last Etag",
|
||||||
|
},
|
||||||
|
events_count: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Events Count",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
required: ["status"],
|
||||||
|
title: "ICSStatus",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const $ICSSyncResult = {
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
title: "Status",
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Hash",
|
||||||
|
},
|
||||||
|
events_found: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Events Found",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
events_created: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Events Created",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
events_updated: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Events Updated",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
events_deleted: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Events Deleted",
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "object",
|
||||||
|
required: ["status"],
|
||||||
|
title: "ICSSyncResult",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const $Meeting = {
|
export const $Meeting = {
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
@@ -950,6 +1179,50 @@ export const $Room = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
title: "Is Shared",
|
title: "Is Shared",
|
||||||
},
|
},
|
||||||
|
ics_url: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Url",
|
||||||
|
},
|
||||||
|
ics_fetch_interval: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Ics Fetch Interval",
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
ics_enabled: {
|
||||||
|
type: "boolean",
|
||||||
|
title: "Ics Enabled",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
ics_last_sync: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Last Sync",
|
||||||
|
},
|
||||||
|
ics_last_etag: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Last Etag",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
required: [
|
||||||
@@ -1294,54 +1567,139 @@ export const $UpdateParticipant = {
|
|||||||
export const $UpdateRoom = {
|
export const $UpdateRoom = {
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Name",
|
title: "Name",
|
||||||
},
|
},
|
||||||
zulip_auto_post: {
|
zulip_auto_post: {
|
||||||
type: "boolean",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Zulip Auto Post",
|
title: "Zulip Auto Post",
|
||||||
},
|
},
|
||||||
zulip_stream: {
|
zulip_stream: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Zulip Stream",
|
title: "Zulip Stream",
|
||||||
},
|
},
|
||||||
zulip_topic: {
|
zulip_topic: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Zulip Topic",
|
title: "Zulip Topic",
|
||||||
},
|
},
|
||||||
is_locked: {
|
is_locked: {
|
||||||
type: "boolean",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Is Locked",
|
title: "Is Locked",
|
||||||
},
|
},
|
||||||
room_mode: {
|
room_mode: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Room Mode",
|
title: "Room Mode",
|
||||||
},
|
},
|
||||||
recording_type: {
|
recording_type: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Recording Type",
|
title: "Recording Type",
|
||||||
},
|
},
|
||||||
recording_trigger: {
|
recording_trigger: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Recording Trigger",
|
title: "Recording Trigger",
|
||||||
},
|
},
|
||||||
is_shared: {
|
is_shared: {
|
||||||
type: "boolean",
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
title: "Is Shared",
|
title: "Is Shared",
|
||||||
},
|
},
|
||||||
|
ics_url: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Url",
|
||||||
|
},
|
||||||
|
ics_fetch_interval: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Fetch Interval",
|
||||||
|
},
|
||||||
|
ics_enabled: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
title: "Ics Enabled",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "object",
|
type: "object",
|
||||||
required: [
|
|
||||||
"name",
|
|
||||||
"zulip_auto_post",
|
|
||||||
"zulip_stream",
|
|
||||||
"zulip_topic",
|
|
||||||
"is_locked",
|
|
||||||
"room_mode",
|
|
||||||
"recording_type",
|
|
||||||
"recording_trigger",
|
|
||||||
"is_shared",
|
|
||||||
],
|
|
||||||
title: "UpdateRoom",
|
title: "UpdateRoom",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ import type {
|
|||||||
V1RoomsDeleteResponse,
|
V1RoomsDeleteResponse,
|
||||||
V1RoomsCreateMeetingData,
|
V1RoomsCreateMeetingData,
|
||||||
V1RoomsCreateMeetingResponse,
|
V1RoomsCreateMeetingResponse,
|
||||||
|
V1RoomsSyncIcsData,
|
||||||
|
V1RoomsSyncIcsResponse,
|
||||||
|
V1RoomsIcsStatusData,
|
||||||
|
V1RoomsIcsStatusResponse,
|
||||||
|
V1RoomsListMeetingsData,
|
||||||
|
V1RoomsListMeetingsResponse,
|
||||||
|
V1RoomsListUpcomingMeetingsData,
|
||||||
|
V1RoomsListUpcomingMeetingsResponse,
|
||||||
|
V1RoomsListActiveMeetingsData,
|
||||||
|
V1RoomsListActiveMeetingsResponse,
|
||||||
|
V1RoomsJoinMeetingData,
|
||||||
|
V1RoomsJoinMeetingResponse,
|
||||||
V1TranscriptsListData,
|
V1TranscriptsListData,
|
||||||
V1TranscriptsListResponse,
|
V1TranscriptsListResponse,
|
||||||
V1TranscriptsCreateData,
|
V1TranscriptsCreateData,
|
||||||
@@ -227,6 +239,146 @@ export class DefaultService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms Sync Ics
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @returns ICSSyncResult Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsSyncIcs(
|
||||||
|
data: V1RoomsSyncIcsData,
|
||||||
|
): CancelablePromise<V1RoomsSyncIcsResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/rooms/{room_name}/ics/sync",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms Ics Status
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @returns ICSStatus Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsIcsStatus(
|
||||||
|
data: V1RoomsIcsStatusData,
|
||||||
|
): CancelablePromise<V1RoomsIcsStatusResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/rooms/{room_name}/ics/status",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms List Meetings
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @returns CalendarEventResponse Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsListMeetings(
|
||||||
|
data: V1RoomsListMeetingsData,
|
||||||
|
): CancelablePromise<V1RoomsListMeetingsResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/rooms/{room_name}/meetings",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms List Upcoming Meetings
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @param data.minutesAhead
|
||||||
|
* @returns CalendarEventResponse Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsListUpcomingMeetings(
|
||||||
|
data: V1RoomsListUpcomingMeetingsData,
|
||||||
|
): CancelablePromise<V1RoomsListUpcomingMeetingsResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/rooms/{room_name}/meetings/upcoming",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
minutes_ahead: data.minutesAhead,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms List Active Meetings
|
||||||
|
* List all active meetings for a room (supports multiple active meetings)
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @returns Meeting Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsListActiveMeetings(
|
||||||
|
data: V1RoomsListActiveMeetingsData,
|
||||||
|
): CancelablePromise<V1RoomsListActiveMeetingsResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/rooms/{room_name}/meetings/active",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rooms Join Meeting
|
||||||
|
* Join a specific meeting by ID
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.roomName
|
||||||
|
* @param data.meetingId
|
||||||
|
* @returns Meeting Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public v1RoomsJoinMeeting(
|
||||||
|
data: V1RoomsJoinMeetingData,
|
||||||
|
): CancelablePromise<V1RoomsJoinMeetingResponse> {
|
||||||
|
return this.httpRequest.request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/rooms/{room_name}/meetings/{meeting_id}/join",
|
||||||
|
path: {
|
||||||
|
room_name: data.roomName,
|
||||||
|
meeting_id: data.meetingId,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: "Validation Error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcripts List
|
* Transcripts List
|
||||||
* @param data The data for the request.
|
* @param data The data for the request.
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ export type Body_transcript_record_upload_v1_transcripts__transcript_id__record_
|
|||||||
chunk: Blob | File;
|
chunk: Blob | File;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CalendarEventResponse = {
|
||||||
|
id: string;
|
||||||
|
room_id: string;
|
||||||
|
ics_uid: string;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
attendees?: Array<{
|
||||||
|
[key: string]: unknown;
|
||||||
|
}> | null;
|
||||||
|
location?: string | null;
|
||||||
|
last_synced: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateParticipant = {
|
export type CreateParticipant = {
|
||||||
speaker?: number | null;
|
speaker?: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,6 +41,9 @@ export type CreateRoom = {
|
|||||||
recording_type: string;
|
recording_type: string;
|
||||||
recording_trigger: string;
|
recording_trigger: string;
|
||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
|
ics_url?: string | null;
|
||||||
|
ics_fetch_interval?: number;
|
||||||
|
ics_enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateTranscript = {
|
export type CreateTranscript = {
|
||||||
@@ -123,6 +143,24 @@ export type HTTPValidationError = {
|
|||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ICSStatus = {
|
||||||
|
status: string;
|
||||||
|
last_sync?: string | null;
|
||||||
|
next_sync?: string | null;
|
||||||
|
last_etag?: string | null;
|
||||||
|
events_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ICSSyncResult = {
|
||||||
|
status: string;
|
||||||
|
hash?: string | null;
|
||||||
|
events_found?: number;
|
||||||
|
events_created?: number;
|
||||||
|
events_updated?: number;
|
||||||
|
events_deleted?: number;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Meeting = {
|
export type Meeting = {
|
||||||
id: string;
|
id: string;
|
||||||
room_name: string;
|
room_name: string;
|
||||||
@@ -174,6 +212,11 @@ export type Room = {
|
|||||||
recording_type: string;
|
recording_type: string;
|
||||||
recording_trigger: string;
|
recording_trigger: string;
|
||||||
is_shared: boolean;
|
is_shared: boolean;
|
||||||
|
ics_url?: string | null;
|
||||||
|
ics_fetch_interval?: number;
|
||||||
|
ics_enabled?: boolean;
|
||||||
|
ics_last_sync?: string | null;
|
||||||
|
ics_last_etag?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RtcOffer = {
|
export type RtcOffer = {
|
||||||
@@ -266,15 +309,18 @@ export type UpdateParticipant = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateRoom = {
|
export type UpdateRoom = {
|
||||||
name: string;
|
name?: string | null;
|
||||||
zulip_auto_post: boolean;
|
zulip_auto_post?: boolean | null;
|
||||||
zulip_stream: string;
|
zulip_stream?: string | null;
|
||||||
zulip_topic: string;
|
zulip_topic?: string | null;
|
||||||
is_locked: boolean;
|
is_locked?: boolean | null;
|
||||||
room_mode: string;
|
room_mode?: string | null;
|
||||||
recording_type: string;
|
recording_type?: string | null;
|
||||||
recording_trigger: string;
|
recording_trigger?: string | null;
|
||||||
is_shared: boolean;
|
is_shared?: boolean | null;
|
||||||
|
ics_url?: string | null;
|
||||||
|
ics_fetch_interval?: number | null;
|
||||||
|
ics_enabled?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateTranscript = {
|
export type UpdateTranscript = {
|
||||||
@@ -371,6 +417,44 @@ export type V1RoomsCreateMeetingData = {
|
|||||||
|
|
||||||
export type V1RoomsCreateMeetingResponse = Meeting;
|
export type V1RoomsCreateMeetingResponse = Meeting;
|
||||||
|
|
||||||
|
export type V1RoomsSyncIcsData = {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsSyncIcsResponse = ICSSyncResult;
|
||||||
|
|
||||||
|
export type V1RoomsIcsStatusData = {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsIcsStatusResponse = ICSStatus;
|
||||||
|
|
||||||
|
export type V1RoomsListMeetingsData = {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsListMeetingsResponse = Array<CalendarEventResponse>;
|
||||||
|
|
||||||
|
export type V1RoomsListUpcomingMeetingsData = {
|
||||||
|
minutesAhead?: number;
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsListUpcomingMeetingsResponse = Array<CalendarEventResponse>;
|
||||||
|
|
||||||
|
export type V1RoomsListActiveMeetingsData = {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsListActiveMeetingsResponse = Array<Meeting>;
|
||||||
|
|
||||||
|
export type V1RoomsJoinMeetingData = {
|
||||||
|
meetingId: string;
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V1RoomsJoinMeetingResponse = Meeting;
|
||||||
|
|
||||||
export type V1TranscriptsListData = {
|
export type V1TranscriptsListData = {
|
||||||
/**
|
/**
|
||||||
* Page number
|
* Page number
|
||||||
@@ -670,6 +754,96 @@ export type $OpenApiTs = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"/v1/rooms/{room_name}/ics/sync": {
|
||||||
|
post: {
|
||||||
|
req: V1RoomsSyncIcsData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: ICSSyncResult;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/rooms/{room_name}/ics/status": {
|
||||||
|
get: {
|
||||||
|
req: V1RoomsIcsStatusData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: ICSStatus;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/rooms/{room_name}/meetings": {
|
||||||
|
get: {
|
||||||
|
req: V1RoomsListMeetingsData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<CalendarEventResponse>;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/rooms/{room_name}/meetings/upcoming": {
|
||||||
|
get: {
|
||||||
|
req: V1RoomsListUpcomingMeetingsData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<CalendarEventResponse>;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/rooms/{room_name}/meetings/active": {
|
||||||
|
get: {
|
||||||
|
req: V1RoomsListActiveMeetingsData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Array<Meeting>;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"/v1/rooms/{room_name}/meetings/{meeting_id}/join": {
|
||||||
|
post: {
|
||||||
|
req: V1RoomsJoinMeetingData;
|
||||||
|
res: {
|
||||||
|
/**
|
||||||
|
* Successful Response
|
||||||
|
*/
|
||||||
|
200: Meeting;
|
||||||
|
/**
|
||||||
|
* Validation Error
|
||||||
|
*/
|
||||||
|
422: HTTPValidationError;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
"/v1/transcripts": {
|
"/v1/transcripts": {
|
||||||
get: {
|
get: {
|
||||||
req: V1TranscriptsListData;
|
req: V1TranscriptsListData;
|
||||||
|
|||||||
147
www/app/room/[roomName]/page.tsx
Normal file
147
www/app/room/[roomName]/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Box, Spinner, VStack, Text } from "@chakra-ui/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import useApi from "../../lib/useApi";
|
||||||
|
import useSessionStatus from "../../lib/useSessionStatus";
|
||||||
|
import MeetingSelection from "../../[roomName]/MeetingSelection";
|
||||||
|
import { Meeting, Room } from "../../api";
|
||||||
|
|
||||||
|
interface RoomPageProps {
|
||||||
|
params: {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomPage({ params }: RoomPageProps) {
|
||||||
|
const { roomName } = params;
|
||||||
|
const router = useRouter();
|
||||||
|
const api = useApi();
|
||||||
|
const { data: session } = useSessionStatus();
|
||||||
|
|
||||||
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checkingMeetings, setCheckingMeetings] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = session?.user?.id === room?.user_id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const fetchRoom = async () => {
|
||||||
|
try {
|
||||||
|
// Get room details
|
||||||
|
const roomData = await api.v1RoomsRetrieve({ roomName });
|
||||||
|
setRoom(roomData);
|
||||||
|
|
||||||
|
// Check if we should show meeting selection
|
||||||
|
if (roomData.ics_enabled) {
|
||||||
|
setCheckingMeetings(true);
|
||||||
|
|
||||||
|
// Check for active meetings
|
||||||
|
const activeMeetings = await api.v1RoomsListActiveMeetings({
|
||||||
|
roomName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for upcoming meetings
|
||||||
|
const upcomingEvents = await api.v1RoomsListUpcomingMeetings({
|
||||||
|
roomName,
|
||||||
|
minutesAhead: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there's only one active meeting and no upcoming, auto-join
|
||||||
|
if (activeMeetings.length === 1 && upcomingEvents.length === 0) {
|
||||||
|
handleMeetingSelect(activeMeetings[0]);
|
||||||
|
} else if (
|
||||||
|
activeMeetings.length === 0 &&
|
||||||
|
upcomingEvents.length === 0
|
||||||
|
) {
|
||||||
|
// No meetings, create unscheduled
|
||||||
|
handleCreateUnscheduled();
|
||||||
|
}
|
||||||
|
// Otherwise, show selection UI (handled by render)
|
||||||
|
} else {
|
||||||
|
// ICS not enabled, use traditional flow
|
||||||
|
handleCreateUnscheduled();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch room:", err);
|
||||||
|
// Room not found or error
|
||||||
|
router.push("/rooms");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setCheckingMeetings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRoom();
|
||||||
|
}, [api, roomName]);
|
||||||
|
|
||||||
|
const handleMeetingSelect = (meeting: Meeting) => {
|
||||||
|
// Navigate to the classic room page with the meeting
|
||||||
|
// Store meeting in session storage for the classic page to use
|
||||||
|
sessionStorage.setItem(`meeting_${roomName}`, JSON.stringify(meeting));
|
||||||
|
router.push(`/${roomName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUnscheduled = async () => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new unscheduled meeting
|
||||||
|
const meeting = await api.v1RoomsCreateMeeting({ roomName });
|
||||||
|
handleMeetingSelect(meeting);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create meeting:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || checkingMeetings) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<Spinner size="xl" color="blue.500" />
|
||||||
|
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<Text fontSize="lg">Room not found</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show meeting selection if ICS is enabled and we have multiple options
|
||||||
|
if (room.ics_enabled) {
|
||||||
|
return (
|
||||||
|
<Box minH="100vh" bg="gray.50">
|
||||||
|
<MeetingSelection
|
||||||
|
roomName={roomName}
|
||||||
|
isOwner={isOwner}
|
||||||
|
onMeetingSelect={handleMeetingSelect}
|
||||||
|
onCreateUnscheduled={handleCreateUnscheduled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here - redirected above
|
||||||
|
return null;
|
||||||
|
}
|
||||||
277
www/app/room/[roomName]/wait/page.tsx
Normal file
277
www/app/room/[roomName]/wait/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Spinner,
|
||||||
|
Progress,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { FaClock, FaArrowLeft } from "react-icons/fa";
|
||||||
|
import useApi from "../../../lib/useApi";
|
||||||
|
import { CalendarEventResponse } from "../../../api";
|
||||||
|
|
||||||
|
interface WaitingPageProps {
|
||||||
|
params: {
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaitingPage({ params }: WaitingPageProps) {
|
||||||
|
const { roomName } = params;
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const eventId = searchParams.get("eventId");
|
||||||
|
|
||||||
|
const [event, setEvent] = useState<CalendarEventResponse | null>(null);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checkingMeeting, setCheckingMeeting] = useState(false);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !eventId) return;
|
||||||
|
|
||||||
|
const fetchEvent = async () => {
|
||||||
|
try {
|
||||||
|
const events = await api.v1RoomsListUpcomingMeetings({
|
||||||
|
roomName,
|
||||||
|
minutesAhead: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetEvent = events.find((e) => e.id === eventId);
|
||||||
|
if (targetEvent) {
|
||||||
|
setEvent(targetEvent);
|
||||||
|
} else {
|
||||||
|
// Event not found or already started
|
||||||
|
router.push(`/room/${roomName}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch event:", err);
|
||||||
|
router.push(`/room/${roomName}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEvent();
|
||||||
|
}, [api, eventId, roomName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(event.start_time);
|
||||||
|
const diff = Math.max(0, start.getTime() - now.getTime());
|
||||||
|
|
||||||
|
setTimeRemaining(diff);
|
||||||
|
|
||||||
|
// Check if meeting has started
|
||||||
|
if (diff <= 0) {
|
||||||
|
checkForActiveMeeting();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForActiveMeeting = async () => {
|
||||||
|
if (!api || checkingMeeting) return;
|
||||||
|
|
||||||
|
setCheckingMeeting(true);
|
||||||
|
try {
|
||||||
|
// Check for active meetings
|
||||||
|
const activeMeetings = await api.v1RoomsListActiveMeetings({
|
||||||
|
roomName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find meeting for this calendar event
|
||||||
|
const calendarMeeting = activeMeetings.find(
|
||||||
|
(m) => m.calendar_event_id === eventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (calendarMeeting) {
|
||||||
|
// Meeting is now active, join it
|
||||||
|
const meeting = await api.v1RoomsJoinMeeting({
|
||||||
|
roomName,
|
||||||
|
meetingId: calendarMeeting.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to the meeting room
|
||||||
|
router.push(`/${roomName}?meetingId=${meeting.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check for active meeting:", err);
|
||||||
|
} finally {
|
||||||
|
setCheckingMeeting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update countdown every second
|
||||||
|
const interval = setInterval(updateCountdown, 1000);
|
||||||
|
|
||||||
|
// Check for meeting every 10 seconds when close to start time
|
||||||
|
let checkInterval: NodeJS.Timeout | null = null;
|
||||||
|
if (timeRemaining < 60000) {
|
||||||
|
// Less than 1 minute
|
||||||
|
checkInterval = setInterval(checkForActiveMeeting, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCountdown(); // Initial update
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
if (checkInterval) clearInterval(checkInterval);
|
||||||
|
};
|
||||||
|
}, [event, api, eventId, roomName, checkingMeeting]);
|
||||||
|
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressValue = () => {
|
||||||
|
if (!event) return 0;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const created = new Date(event.created_at);
|
||||||
|
const start = new Date(event.start_time);
|
||||||
|
const totalTime = start.getTime() - created.getTime();
|
||||||
|
const elapsed = now.getTime() - created.getTime();
|
||||||
|
|
||||||
|
return Math.min(100, (elapsed / totalTime) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<Spinner size="xl" color="blue.500" />
|
||||||
|
<Text>Loading meeting details...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<Text fontSize="lg">Meeting not found</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={<FaArrowLeft />}
|
||||||
|
onClick={() => router.push(`/room/${roomName}`)}
|
||||||
|
>
|
||||||
|
Back to Room
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
<Card maxW="lg" width="100%" mx={4}>
|
||||||
|
<CardBody>
|
||||||
|
<VStack spacing={6} py={4}>
|
||||||
|
<Icon as={FaClock} boxSize={16} color="blue.500" />
|
||||||
|
|
||||||
|
<VStack spacing={2}>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold">
|
||||||
|
{event.title || "Scheduled Meeting"}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray.600" textAlign="center">
|
||||||
|
The meeting will start automatically when it's time
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Box width="100%">
|
||||||
|
<Text
|
||||||
|
fontSize="4xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAlign="center"
|
||||||
|
color="blue.600"
|
||||||
|
>
|
||||||
|
{formatTime(timeRemaining)}
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
value={getProgressValue()}
|
||||||
|
colorScheme="blue"
|
||||||
|
size="sm"
|
||||||
|
mt={4}
|
||||||
|
borderRadius="full"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{event.description && (
|
||||||
|
<Box width="100%" p={4} bg="gray.100" borderRadius="md">
|
||||||
|
<Text fontSize="sm" fontWeight="semibold" mb={1}>
|
||||||
|
Meeting Description
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.700">
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack spacing={3} width="100%">
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
Scheduled for {new Date(event.start_time).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{checkingMeeting && (
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Spinner size="sm" color="blue.500" />
|
||||||
|
<Text fontSize="sm" color="blue.600">
|
||||||
|
Checking if meeting has started...
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<FaArrowLeft />}
|
||||||
|
onClick={() => router.push(`/room/${roomName}`)}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
Back to Meeting Selection
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user