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