mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 04:09:06 +00:00
* docker-compose for production frontend * fix: Remove external Redis port mapping for Coolify compatibility Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications. * fix: Remove external port mapping for web service in Coolify Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file. * server side client envs * missing vars * nextjs experimental * fix claude 'fix' * remove build env vars compose * docker * remove ports for coolify * review * cleanup --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
346 lines
9.9 KiB
TypeScript
346 lines
9.9 KiB
TypeScript
import React, { useState } from "react";
|
|
import {
|
|
Box,
|
|
Table,
|
|
Link,
|
|
Flex,
|
|
IconButton,
|
|
Text,
|
|
Spinner,
|
|
Badge,
|
|
VStack,
|
|
Icon,
|
|
} from "@chakra-ui/react";
|
|
import { LuLink, LuRefreshCw } from "react-icons/lu";
|
|
import { FaCalendarAlt } from "react-icons/fa";
|
|
import type { components } from "../../../reflector-api";
|
|
import {
|
|
useRoomActiveMeetings,
|
|
useRoomUpcomingMeetings,
|
|
useRoomIcsSync,
|
|
} from "../../../lib/apiHooks";
|
|
|
|
type Room = components["schemas"]["Room"];
|
|
type Meeting = components["schemas"]["Meeting"];
|
|
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
|
|
import { RoomActionsMenu } from "./RoomActionsMenu";
|
|
import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants";
|
|
import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils";
|
|
|
|
// Custom icon component that combines calendar and refresh icons
|
|
const CalendarSyncIcon = () => (
|
|
<Box position="relative" display="inline-block" w="20px" h="20px">
|
|
<Icon
|
|
as={FaCalendarAlt}
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
boxSize="20px"
|
|
/>
|
|
<Box
|
|
position="absolute"
|
|
bottom="-2px"
|
|
right="-2px"
|
|
bg="white"
|
|
borderRadius="full"
|
|
p="1px"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
w="12px"
|
|
h="12px"
|
|
>
|
|
<Icon as={LuRefreshCw} boxSize="10px" color="gray.700" />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
interface RoomTableProps {
|
|
rooms: Room[];
|
|
linkCopied: string;
|
|
onCopyUrl: (roomName: NonEmptyString) => void;
|
|
onEdit: (roomId: string, roomData: any) => void;
|
|
onDelete: (roomId: string) => void;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const getRoomModeDisplay = (mode: string): string => {
|
|
switch (mode) {
|
|
case "normal":
|
|
return "2-4 people";
|
|
case "group":
|
|
return "2-200 people";
|
|
default:
|
|
return mode;
|
|
}
|
|
};
|
|
|
|
const getRecordingDisplay = (type: string, trigger: string): string => {
|
|
if (type === "none") return "-";
|
|
if (type === "local") return "Local";
|
|
if (type === "cloud") {
|
|
switch (trigger) {
|
|
case "none":
|
|
return "Cloud (None)";
|
|
case "prompt":
|
|
return "Cloud (Prompt)";
|
|
case "automatic-2nd-participant":
|
|
return "Cloud (Auto)";
|
|
default:
|
|
return `Cloud (${trigger})`;
|
|
}
|
|
}
|
|
return type;
|
|
};
|
|
|
|
const getZulipDisplay = (
|
|
autoPost: boolean,
|
|
stream: string,
|
|
topic: string,
|
|
): string => {
|
|
if (!autoPost) return "-";
|
|
if (stream && topic) return `${stream} > ${topic}`;
|
|
if (stream) return stream;
|
|
return "Enabled";
|
|
};
|
|
|
|
function MeetingStatus({ roomName }: { roomName: string }) {
|
|
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
|
|
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
|
|
|
|
const activeMeetings = activeMeetingsQuery.data || [];
|
|
const upcomingMeetings = upcomingMeetingsQuery.data || [];
|
|
|
|
if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
|
|
return <Spinner size="sm" />;
|
|
}
|
|
|
|
if (activeMeetings.length > 0) {
|
|
const meeting = activeMeetings[0];
|
|
const title = String(
|
|
meeting.calendar_metadata?.["title"] || "Active Meeting",
|
|
);
|
|
return (
|
|
<VStack gap={1} alignItems="start">
|
|
<Text fontSize="xs" color="gray.600" lineHeight={1}>
|
|
{title}
|
|
</Text>
|
|
<Text fontSize="xs" color="gray.500" lineHeight={1}>
|
|
{meeting.num_clients} participants
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
if (upcomingMeetings.length > 0) {
|
|
const event = upcomingMeetings[0];
|
|
const startTime = new Date(event.start_time);
|
|
const now = new Date();
|
|
const diffMinutes = Math.floor(
|
|
(startTime.getTime() - now.getTime()) / 60000,
|
|
);
|
|
|
|
return (
|
|
<VStack gap={1} alignItems="start">
|
|
<Badge colorScheme="orange" size="sm">
|
|
{diffMinutes < MEETING_DEFAULT_TIME_MINUTES
|
|
? `In ${diffMinutes}m`
|
|
: "Upcoming"}
|
|
</Badge>
|
|
<Text fontSize="xs" color="gray.600" lineHeight={1}>
|
|
{event.title || "Scheduled Meeting"}
|
|
</Text>
|
|
<Text fontSize="xs" color="gray.500" lineHeight={1}>
|
|
{startTime.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Text fontSize="xs" color="gray.500">
|
|
No meetings
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
export function RoomTable({
|
|
rooms,
|
|
linkCopied,
|
|
onCopyUrl,
|
|
onEdit,
|
|
onDelete,
|
|
loading,
|
|
}: RoomTableProps) {
|
|
const [syncingRooms, setSyncingRooms] = useState<Set<NonEmptyString>>(
|
|
new Set(),
|
|
);
|
|
const syncMutation = useRoomIcsSync();
|
|
|
|
const handleForceSync = async (roomName: NonEmptyString) => {
|
|
setSyncingRooms((prev) => new Set(prev).add(roomName));
|
|
try {
|
|
await syncMutation.mutateAsync({
|
|
params: {
|
|
path: { room_name: roomName },
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to sync calendar:", err);
|
|
} finally {
|
|
setSyncingRooms((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(roomName);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box display={{ base: "none", lg: "block" }} position="relative">
|
|
{loading && (
|
|
<Flex
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
bottom={0}
|
|
align="center"
|
|
justify="center"
|
|
>
|
|
<Spinner size="xl" color="gray.700" />
|
|
</Flex>
|
|
)}
|
|
<Box
|
|
opacity={loading ? 0.9 : 1}
|
|
pointerEvents={loading ? "none" : "auto"}
|
|
transition="opacity 0.2s ease-in-out"
|
|
>
|
|
<Table.Root>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.ColumnHeader width="250px" fontWeight="600">
|
|
Room Name
|
|
</Table.ColumnHeader>
|
|
<Table.ColumnHeader width="200px" fontWeight="600">
|
|
Current Meeting
|
|
</Table.ColumnHeader>
|
|
<Table.ColumnHeader width="200px" fontWeight="600">
|
|
Zulip
|
|
</Table.ColumnHeader>
|
|
<Table.ColumnHeader width="120px" fontWeight="600">
|
|
Room Size
|
|
</Table.ColumnHeader>
|
|
<Table.ColumnHeader width="150px" fontWeight="600">
|
|
Recording
|
|
</Table.ColumnHeader>
|
|
<Table.ColumnHeader
|
|
width="100px"
|
|
fontWeight="600"
|
|
></Table.ColumnHeader>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{rooms.map((room) => (
|
|
<Table.Row key={room.id}>
|
|
<Table.Cell>
|
|
<Link href={`/${room.name}`}>{room.name}</Link>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<MeetingStatus roomName={room.name} />
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
{getZulipDisplay(
|
|
room.zulip_auto_post,
|
|
room.zulip_stream,
|
|
room.zulip_topic,
|
|
)}
|
|
</Table.Cell>
|
|
<Table.Cell>{getRoomModeDisplay(room.room_mode)}</Table.Cell>
|
|
<Table.Cell>
|
|
{getRecordingDisplay(
|
|
room.recording_type,
|
|
room.recording_trigger,
|
|
)}
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<Flex alignItems="center" gap={2} justifyContent="flex-end">
|
|
{room.ics_enabled && (
|
|
<IconButton
|
|
aria-label="Force sync calendar"
|
|
onClick={() =>
|
|
handleForceSync(
|
|
parseNonEmptyString(
|
|
room.name,
|
|
true,
|
|
"panic! room.name is required",
|
|
),
|
|
)
|
|
}
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={syncingRooms.has(
|
|
parseNonEmptyString(
|
|
room.name,
|
|
true,
|
|
"panic! room.name is required",
|
|
),
|
|
)}
|
|
>
|
|
{syncingRooms.has(
|
|
parseNonEmptyString(
|
|
room.name,
|
|
true,
|
|
"panic! room.name is required",
|
|
),
|
|
) ? (
|
|
<Spinner size="sm" />
|
|
) : (
|
|
<CalendarSyncIcon />
|
|
)}
|
|
</IconButton>
|
|
)}
|
|
{linkCopied === room.name ? (
|
|
<Text color="green.500" fontSize="sm">
|
|
Copied!
|
|
</Text>
|
|
) : (
|
|
<IconButton
|
|
aria-label="Copy URL"
|
|
onClick={() =>
|
|
onCopyUrl(
|
|
parseNonEmptyString(
|
|
room.name,
|
|
true,
|
|
"panic! room.name is required",
|
|
),
|
|
)
|
|
}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
<LuLink />
|
|
</IconButton>
|
|
)}
|
|
<RoomActionsMenu
|
|
roomId={room.id}
|
|
roomData={room}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
/>
|
|
</Flex>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
))}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|