mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-22 13:19: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:
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
createListCollection,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import useApi from "../../../lib/useApi";
|
||||
|
||||
interface ICSSettingsProps {
|
||||
roomId?: string;
|
||||
roomName?: string;
|
||||
icsUrl?: string;
|
||||
icsEnabled?: boolean;
|
||||
icsFetchInterval?: number;
|
||||
icsLastSync?: string;
|
||||
icsLastEtag?: string;
|
||||
onChange: (settings: Partial<ICSSettingsData>) => void;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export interface ICSSettingsData {
|
||||
ics_url: string;
|
||||
ics_enabled: boolean;
|
||||
ics_fetch_interval: number;
|
||||
}
|
||||
|
||||
const fetchIntervalOptions = [
|
||||
{ label: "1 minute", value: "1" },
|
||||
{ label: "5 minutes", value: "5" },
|
||||
{ label: "10 minutes", value: "10" },
|
||||
{ label: "30 minutes", value: "30" },
|
||||
{ label: "1 hour", value: "60" },
|
||||
];
|
||||
|
||||
export default function ICSSettings({
|
||||
roomId,
|
||||
roomName,
|
||||
icsUrl = "",
|
||||
icsEnabled = false,
|
||||
icsFetchInterval = 5,
|
||||
icsLastSync,
|
||||
icsLastEtag,
|
||||
onChange,
|
||||
isOwner = true,
|
||||
}: ICSSettingsProps) {
|
||||
const [syncStatus, setSyncStatus] = useState<
|
||||
"idle" | "syncing" | "success" | "error"
|
||||
>("idle");
|
||||
const [syncMessage, setSyncMessage] = useState<string>("");
|
||||
const [testResult, setTestResult] = useState<string>("");
|
||||
const api = useApi();
|
||||
|
||||
const fetchIntervalCollection = createListCollection({
|
||||
items: fetchIntervalOptions,
|
||||
});
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!api || !icsUrl || !roomName) return;
|
||||
|
||||
setSyncStatus("syncing");
|
||||
setTestResult("");
|
||||
|
||||
try {
|
||||
// First update the room with the ICS URL
|
||||
await api.v1RoomsPartialUpdate({
|
||||
roomId: roomId || roomName,
|
||||
requestBody: {
|
||||
ics_url: icsUrl,
|
||||
ics_enabled: true,
|
||||
ics_fetch_interval: icsFetchInterval,
|
||||
},
|
||||
});
|
||||
|
||||
// Then trigger a sync
|
||||
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||
|
||||
if (result.status === "success") {
|
||||
setSyncStatus("success");
|
||||
setTestResult(
|
||||
`Successfully synced! Found ${result.events_found} events.`,
|
||||
);
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setTestResult(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setTestResult(err.body?.detail || "Failed to test ICS connection");
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSync = async () => {
|
||||
if (!api || !roomName) return;
|
||||
|
||||
setSyncStatus("syncing");
|
||||
setSyncMessage("");
|
||||
|
||||
try {
|
||||
const result = await api.v1RoomsTriggerIcsSync({ roomName });
|
||||
|
||||
if (result.status === "success") {
|
||||
setSyncStatus("success");
|
||||
setSyncMessage(
|
||||
`Sync complete! Found ${result.events_found} events, ` +
|
||||
`created ${result.events_created}, updated ${result.events_updated}.`,
|
||||
);
|
||||
} else {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(result.error || "Sync failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSyncStatus("error");
|
||||
setSyncMessage(err.body?.detail || "Failed to sync calendar");
|
||||
}
|
||||
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSyncStatus("idle");
|
||||
setSyncMessage("");
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
if (!isOwner) {
|
||||
return null; // ICS settings only visible to room owner
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch" mt={6}>
|
||||
<Text fontWeight="semibold" fontSize="lg">
|
||||
Calendar Integration (ICS)
|
||||
</Text>
|
||||
|
||||
<Field.Root>
|
||||
<Checkbox.Root
|
||||
checked={icsEnabled}
|
||||
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
{icsEnabled && (
|
||||
<>
|
||||
<Field.Root>
|
||||
<Field.Label>ICS Calendar URL</Field.Label>
|
||||
<Input
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={icsUrl}
|
||||
onChange={(e) => onChange({ ics_url: e.target.value })}
|
||||
/>
|
||||
<Field.HelperText>
|
||||
Enter the ICS URL from Google Calendar, Outlook, or other calendar
|
||||
services
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root>
|
||||
<Field.Label>Sync Interval</Field.Label>
|
||||
<Select.Root
|
||||
collection={fetchIntervalCollection}
|
||||
value={[icsFetchInterval.toString()]}
|
||||
onValueChange={(details) => {
|
||||
const value = parseInt(details.value[0]);
|
||||
onChange({ ics_fetch_interval: value });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{fetchIntervalOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Field.HelperText>
|
||||
How often to check for calendar updates
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{icsUrl && (
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={syncStatus === "syncing"}
|
||||
leftIcon={
|
||||
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
|
||||
}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
{roomName && icsLastSync && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleManualSync}
|
||||
disabled={syncStatus === "syncing"}
|
||||
leftIcon={<FaSync />}
|
||||
>
|
||||
Sync Now
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">{testResult}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{syncMessage && (
|
||||
<Alert status={syncStatus === "success" ? "success" : "error"}>
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">{syncMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{icsLastSync && (
|
||||
<HStack spacing={4} fontSize="sm" color="gray.600">
|
||||
<HStack>
|
||||
<FaCheckCircle color="green" />
|
||||
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
|
||||
</HStack>
|
||||
{icsLastEtag && (
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
ETag: {icsLastEtag.slice(0, 8)}...
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { RoomList } from "./_components/RoomList";
|
||||
import { PaginationPage } from "../browse/_components/Pagination";
|
||||
import { assertExists } from "../../lib/utils";
|
||||
import ICSSettings from "./_components/ICSSettings";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
|
||||
@@ -70,6 +71,9 @@ const roomInitialState = {
|
||||
isShared: false,
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
icsUrl: "",
|
||||
icsEnabled: false,
|
||||
icsFetchInterval: 5,
|
||||
};
|
||||
|
||||
export default function RoomsList() {
|
||||
@@ -275,6 +279,9 @@ export default function RoomsList() {
|
||||
is_shared: room.isShared,
|
||||
webhook_url: room.webhookUrl,
|
||||
webhook_secret: room.webhookSecret,
|
||||
ics_url: room.icsUrl,
|
||||
ics_enabled: room.icsEnabled,
|
||||
ics_fetch_interval: room.icsFetchInterval,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -316,6 +323,22 @@ export default function RoomsList() {
|
||||
setShowWebhookSecret(false);
|
||||
setWebhookTestResult(null);
|
||||
|
||||
setRoom({
|
||||
name: roomData.name,
|
||||
zulipAutoPost: roomData.zulip_auto_post,
|
||||
zulipStream: roomData.zulip_stream,
|
||||
zulipTopic: roomData.zulip_topic,
|
||||
isLocked: roomData.is_locked,
|
||||
roomMode: roomData.room_mode,
|
||||
recordingType: roomData.recording_type,
|
||||
recordingTrigger: roomData.recording_trigger,
|
||||
isShared: roomData.is_shared,
|
||||
webhookUrl: roomData.webhook_url || "",
|
||||
webhookSecret: roomData.webhook_secret || "",
|
||||
icsUrl: roomData.ics_url || "",
|
||||
icsEnabled: roomData.ics_enabled || false,
|
||||
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
||||
});
|
||||
setEditRoomId(roomId);
|
||||
setIsEditing(true);
|
||||
setNameError("");
|
||||
@@ -763,6 +786,32 @@ export default function RoomsList() {
|
||||
<Checkbox.Label>Shared room</Checkbox.Label>
|
||||
</Checkbox.Root>
|
||||
</Field.Root>
|
||||
|
||||
<ICSSettings
|
||||
roomId={editRoomId}
|
||||
roomName={room.name}
|
||||
icsUrl={room.icsUrl}
|
||||
icsEnabled={room.icsEnabled}
|
||||
icsFetchInterval={room.icsFetchInterval}
|
||||
onChange={(settings) => {
|
||||
setRoom({
|
||||
...room,
|
||||
icsUrl:
|
||||
settings.ics_url !== undefined
|
||||
? settings.ics_url
|
||||
: room.icsUrl,
|
||||
icsEnabled:
|
||||
settings.ics_enabled !== undefined
|
||||
? settings.ics_enabled
|
||||
: room.icsEnabled,
|
||||
icsFetchInterval:
|
||||
settings.ics_fetch_interval !== undefined
|
||||
? settings.ics_fetch_interval
|
||||
: room.icsFetchInterval,
|
||||
});
|
||||
}}
|
||||
isOwner={true}
|
||||
/>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onClick={handleCloseDialog}>
|
||||
|
||||
Reference in New Issue
Block a user