feat: implement tabbed interface for room edit dialog

- Add General, Calendar, and Share tabs to organize room settings
- Move ICS settings to dedicated Calendar tab
- Move Zulip configuration to Share tab
- Keep basic room settings and webhooks in General tab
- Remove redundant migration file
- Fix Chakra UI v3 compatibility issues in calendar components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-05 17:44:09 -06:00
parent d53edfa8dd
commit 91f9d23632
5 changed files with 489 additions and 504 deletions

View File

@@ -1,46 +0,0 @@
"""add_ics_uid_to_calendar_event
Revision ID: a256772ef058
Revises: d4a1c446458c
Create Date: 2025-08-19 09:27:26.472456
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a256772ef058"
down_revision: Union[str, None] = "d4a1c446458c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
batch_op.add_column(sa.Column("ics_uid", sa.Text(), nullable=False))
batch_op.drop_constraint(batch_op.f("uq_room_calendar_event"), type_="unique")
batch_op.create_unique_constraint(
"uq_room_calendar_event", ["room_id", "ics_uid"]
)
batch_op.drop_column("external_id")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
batch_op.add_column(
sa.Column("external_id", sa.TEXT(), autoincrement=False, nullable=True)
)
batch_op.drop_constraint("uq_room_calendar_event", type_="unique")
batch_op.create_unique_constraint(
batch_op.f("uq_room_calendar_event"), ["room_id", "external_id"]
)
batch_op.drop_column("ics_uid")
# ### end Alembic commands ###

View File

@@ -5,69 +5,60 @@ import {
VStack, VStack,
Heading, Heading,
Text, Text,
Card,
HStack, HStack,
Badge, Badge,
Spinner, Spinner,
Flex, Flex,
Link, Link,
Button, Button,
Alert,
IconButton, IconButton,
Tooltip, Tooltip,
Wrap, Wrap,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useState } from "react";
import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa"; import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa";
import { LuArrowLeft } from "react-icons/lu"; import { LuArrowLeft } from "react-icons/lu";
import useApi from "../../../../lib/useApi"; import {
import { CalendarEventResponse } from "../../../../api"; useRoomCalendarEvents,
useRoomIcsSync,
} from "../../../../lib/apiHooks";
import type { components } from "../../../../reflector-api";
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
export default function RoomCalendarPage() { export default function RoomCalendarPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const roomName = params.roomName as string; const roomName = params.roomName as string;
const api = useApi();
const [events, setEvents] = useState<CalendarEventResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const fetchEvents = async () => { // React Query hooks
if (!api) return; const eventsQuery = useRoomCalendarEvents(roomName);
const syncMutation = useRoomIcsSync();
try { const events = eventsQuery.data || [];
setLoading(true); const loading = eventsQuery.isLoading;
setError(null); const error = eventsQuery.error ? "Failed to load calendar events" : null;
const response = await api.v1RoomsListMeetings({ roomName });
setEvents(response);
} catch (err: any) {
setError(err.body?.detail || "Failed to load calendar events");
} finally {
setLoading(false);
}
};
const handleSync = async () => { const handleSync = async () => {
if (!api) return;
try { try {
setSyncing(true); setSyncing(true);
await api.v1RoomsSyncIcs({ roomName }); await syncMutation.mutateAsync({
await fetchEvents(); // Refresh events after sync params: {
path: { room_name: roomName },
},
});
// Refetch events after sync
await eventsQuery.refetch();
} catch (err: any) { } catch (err: any) {
setError(err.body?.detail || "Failed to sync calendar"); console.error("Sync failed:", err);
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}; };
useEffect(() => {
fetchEvents();
}, [api, roomName]);
const formatEventTime = (start: string, end: string) => { const formatEventTime = (start: string, end: string) => {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
@@ -125,7 +116,7 @@ export default function RoomCalendarPage() {
<HStack fontSize="sm" color="gray.600" flexWrap="wrap"> <HStack fontSize="sm" color="gray.600" flexWrap="wrap">
<FaUsers /> <FaUsers />
<Text>Attendees:</Text> <Text>Attendees:</Text>
<Wrap spacing={2}> <Wrap gap={2}>
{attendees.map((attendee, index) => { {attendees.map((attendee, index) => {
const email = getAttendeeEmail(attendee); const email = getAttendeeEmail(attendee);
const display = getAttendeeDisplay(attendee); const display = getAttendeeDisplay(attendee);
@@ -178,9 +169,9 @@ export default function RoomCalendarPage() {
return ( return (
<Box w={{ base: "full", md: "container.xl" }} mx="auto" pt={2}> <Box w={{ base: "full", md: "container.xl" }} mx="auto" pt={2}>
<VStack align="stretch" spacing={6}> <VStack align="stretch" gap={6}>
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<HStack spacing={3}> <HStack gap={3}>
<IconButton <IconButton
aria-label="Back to rooms" aria-label="Back to rooms"
title="Back to rooms" title="Back to rooms"
@@ -192,21 +183,25 @@ export default function RoomCalendarPage() {
</IconButton> </IconButton>
<Heading size="lg">Calendar for {roomName}</Heading> <Heading size="lg">Calendar for {roomName}</Heading>
</HStack> </HStack>
<Button <Button colorPalette="blue" onClick={handleSync} disabled={syncing}>
colorPalette="blue" {syncing ? <Spinner size="sm" /> : <FaSync />}
onClick={handleSync}
leftIcon={syncing ? <Spinner size="sm" /> : <FaSync />}
disabled={syncing}
>
Force Sync Force Sync
</Button> </Button>
</Flex> </Flex>
{error && ( {error && (
<Alert.Root status="error"> <Box
<Alert.Indicator /> p={4}
<Alert.Title>{error}</Alert.Title> borderRadius="md"
</Alert.Root> bg="red.50"
borderLeft="4px solid"
borderColor="red.400"
>
<Text fontWeight="semibold" color="red.800">
Error
</Text>
<Text color="red.700">{error}</Text>
</Box>
)} )}
{loading ? ( {loading ? (
@@ -214,66 +209,62 @@ export default function RoomCalendarPage() {
<Spinner size="xl" /> <Spinner size="xl" />
</Flex> </Flex>
) : events.length === 0 ? ( ) : events.length === 0 ? (
<Card.Root> <Box bg="white" borderRadius="lg" boxShadow="md" p={6}>
<Card.Body> <Text textAlign="center" color="gray.500">
<Text textAlign="center" color="gray.500"> No calendar events found. Make sure your calendar is configured
No calendar events found. Make sure your calendar is configured and synced.
and synced. </Text>
</Text> </Box>
</Card.Body>
</Card.Root>
) : ( ) : (
<VStack align="stretch" spacing={6}> <VStack align="stretch" gap={6}>
{/* Active Events */} {/* Active Events */}
{activeEvents.length > 0 && ( {activeEvents.length > 0 && (
<Box> <Box>
<Heading size="md" mb={3} color="green.600"> <Heading size="md" mb={3} color="green.600">
Active Now Active Now
</Heading> </Heading>
<VStack align="stretch" spacing={3}> <VStack align="stretch" gap={3}>
{activeEvents.map((event) => ( {activeEvents.map((event) => (
<Card.Root <Box
key={event.id} key={event.id}
bg="white"
borderRadius="lg"
boxShadow="md"
p={6}
borderColor="green.200" borderColor="green.200"
borderWidth={2} borderWidth={2}
> >
<Card.Body> <Flex justify="space-between" align="start">
<Flex justify="space-between" align="start"> <VStack align="start" gap={2} flex={1}>
<VStack align="start" spacing={2} flex={1}> <HStack>
<HStack> <Heading size="sm">
<Heading size="sm"> {event.title || "Untitled Event"}
{event.title || "Untitled Event"} </Heading>
</Heading> <Badge colorPalette="green">Active</Badge>
<Badge colorPalette="green">Active</Badge> </HStack>
</HStack> <HStack fontSize="sm" color="gray.600">
<HStack fontSize="sm" color="gray.600"> <FaClock />
<FaClock /> <Text>
<Text> {formatEventTime(
{formatEventTime( event.start_time,
event.start_time, event.end_time,
event.end_time, )}
)} </Text>
</Text> </HStack>
</HStack> {event.description && (
{event.description && ( <Text fontSize="sm" color="gray.700" noOfLines={2}>
<Text {event.description}
fontSize="sm" </Text>
color="gray.700" )}
noOfLines={2} {renderAttendees(event.attendees)}
> </VStack>
{event.description} <Link href={`/${roomName}`}>
</Text> <Button size="sm" colorPalette="green">
)} Join Room
{renderAttendees(event.attendees)} </Button>
</VStack> </Link>
<Link href={`/${roomName}`}> </Flex>
<Button size="sm" colorPalette="green"> </Box>
Join Room
</Button>
</Link>
</Flex>
</Card.Body>
</Card.Root>
))} ))}
</VStack> </VStack>
</Box> </Box>

View File

@@ -143,11 +143,7 @@ export default function ICSSettings({
} }
return ( return (
<VStack gap={4} align="stretch" mt={6}> <VStack gap={4} align="stretch">
<Text fontWeight="semibold" fontSize="lg">
Calendar Integration (ICS)
</Text>
<Field.Root> <Field.Root>
<Checkbox.Root <Checkbox.Root
checked={icsEnabled} checked={icsEnabled}

View File

@@ -14,6 +14,7 @@ import {
IconButton, IconButton,
createListCollection, createListCollection,
useDisclosure, useDisclosure,
Tabs,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -442,379 +443,405 @@ export default function RoomsList() {
</Dialog.CloseTrigger> </Dialog.CloseTrigger>
</Dialog.Header> </Dialog.Header>
<Dialog.Body> <Dialog.Body>
<Field.Root> <Tabs.Root defaultValue="general">
<Field.Label>Room name</Field.Label> <Tabs.List>
<Input <Tabs.Trigger value="general">General</Tabs.Trigger>
name="name" <Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
placeholder="room-name" <Tabs.Trigger value="share">Share</Tabs.Trigger>
value={room.name} </Tabs.List>
onChange={handleRoomChange}
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && <Field.ErrorText>{nameError}</Field.ErrorText>}
</Field.Root>
<Field.Root mt={4}> <Tabs.Content value="general" pt={6}>
<Checkbox.Root <Field.Root>
name="isLocked" <Field.Label>Room name</Field.Label>
checked={room.isLocked} <Input
onCheckedChange={(e) => { name="name"
const syntheticEvent = { placeholder="room-name"
target: { value={room.name}
name: "isLocked", onChange={handleRoomChange}
type: "checkbox", />
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud" ? "none" : room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={8}>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
{/* Webhook Configuration Section */}
<Field.Root mt={8}>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
type="url"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts are
ready
</Field.HelperText>
</Field.Root>
{room.webhookUrl && (
<>
<Field.Root mt={4}>
<Field.Label>Webhook Secret</Field.Label>
<Flex gap={2}>
<Input
name="webhookSecret"
type={showWebhookSecret ? "text" : "password"}
value={room.webhookSecret}
onChange={handleRoomChange}
placeholder={
isEditing && room.webhookSecret
? "••••••••"
: "Leave empty to auto-generate"
}
flex="1"
/>
{isEditing && room.webhookSecret && (
<IconButton
size="sm"
variant="ghost"
aria-label={
showWebhookSecret ? "Hide secret" : "Show secret"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)}
</Flex>
<Field.HelperText> <Field.HelperText>
Used for HMAC signature verification (auto-generated if No spaces or special characters allowed
left empty) </Field.HelperText>
{nameError && (
<Field.ErrorText>{nameError}</Field.ErrorText>
)}
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud"
? "none"
: room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{/* Webhook Configuration Section */}
<Field.Root mt={8}>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
type="url"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts
are ready
</Field.HelperText> </Field.HelperText>
</Field.Root> </Field.Root>
{isEditing && ( {room.webhookUrl && (
<> <>
<Flex <Field.Root mt={4}>
mt={2} <Field.Label>Webhook Secret</Field.Label>
gap={2} <Flex gap={2}>
alignItems="flex-start" <Input
direction="column" name="webhookSecret"
> type={showWebhookSecret ? "text" : "password"}
<Button value={room.webhookSecret}
size="sm" onChange={handleRoomChange}
variant="outline" placeholder={
onClick={handleTestWebhook} isEditing && room.webhookSecret
disabled={testingWebhook || !room.webhookUrl} ? "••••••••"
> : "Leave empty to auto-generate"
{testingWebhook ? ( }
<> flex="1"
<Spinner size="xs" mr={2} /> />
Testing... {isEditing && room.webhookSecret && (
</> <IconButton
) : ( size="sm"
"Test Webhook" variant="ghost"
aria-label={
showWebhookSecret
? "Hide secret"
: "Show secret"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)} )}
</Button> </Flex>
{webhookTestResult && ( <Field.HelperText>
<div Used for HMAC signature verification (auto-generated
style={{ if left empty)
fontSize: "14px", </Field.HelperText>
wordBreak: "break-word", </Field.Root>
maxWidth: "100%",
padding: "8px", {isEditing && (
borderRadius: "4px", <>
backgroundColor: webhookTestResult.startsWith( <Flex
"✅", mt={2}
) gap={2}
? "#f0fdf4" alignItems="flex-start"
: "#fef2f2", direction="column"
border: `1px solid ${webhookTestResult.startsWith("✅") ? "#86efac" : "#fca5a5"}`,
}}
> >
{webhookTestResult} <Button
</div> size="sm"
)} variant="outline"
</Flex> onClick={handleTestWebhook}
disabled={testingWebhook || !room.webhookUrl}
>
{testingWebhook ? (
<>
<Spinner size="xs" mr={2} />
Testing...
</>
) : (
"Test Webhook"
)}
</Button>
{webhookTestResult && (
<div
style={{
fontSize: "14px",
wordBreak: "break-word",
maxWidth: "100%",
padding: "8px",
borderRadius: "4px",
backgroundColor: webhookTestResult.startsWith(
"✅",
)
? "#f0fdf4"
: "#fef2f2",
border: `1px solid ${webhookTestResult.startsWith("✅") ? "#86efac" : "#fca5a5"}`,
}}
>
{webhookTestResult}
</div>
)}
</Flex>
</>
)}
</> </>
)} )}
</> </Tabs.Content>
)}
<Field.Root mt={4}> <Tabs.Content value="calendar" pt={6}>
<Checkbox.Root <ICSSettings
name="isShared" roomId={editRoomId ?? undefined}
checked={room.isShared} roomName={room.name}
onCheckedChange={(e) => { icsUrl={room.icsUrl}
const syntheticEvent = { icsEnabled={room.icsEnabled}
target: { icsFetchInterval={room.icsFetchInterval}
name: "isShared", onChange={(settings) => {
type: "checkbox", setRoomInput({
checked: e.checked, ...room,
}, icsUrl:
}; settings.ics_url !== undefined
handleRoomChange(syntheticEvent); ? settings.ics_url
}} : room.icsUrl,
> icsEnabled:
<Checkbox.HiddenInput /> settings.ics_enabled !== undefined
<Checkbox.Control> ? settings.ics_enabled
<Checkbox.Indicator /> : room.icsEnabled,
</Checkbox.Control> icsFetchInterval:
<Checkbox.Label>Shared room</Checkbox.Label> settings.ics_fetch_interval !== undefined
</Checkbox.Root> ? settings.ics_fetch_interval
</Field.Root> : room.icsFetchInterval,
});
}}
isOwner={true}
/>
</Tabs.Content>
<ICSSettings <Tabs.Content value="share" pt={6}>
roomId={editRoomId ?? undefined} <Field.Root>
roomName={room.name} <Checkbox.Root
icsUrl={room.icsUrl} name="zulipAutoPost"
icsEnabled={room.icsEnabled} checked={room.zulipAutoPost}
icsFetchInterval={room.icsFetchInterval} onCheckedChange={(e) => {
onChange={(settings) => { const syntheticEvent = {
setRoomInput({ target: {
...room, name: "zulipAutoPost",
icsUrl: type: "checkbox",
settings.ics_url !== undefined checked: e.checked,
? settings.ics_url },
: room.icsUrl, };
icsEnabled: handleRoomChange(syntheticEvent);
settings.ics_enabled !== undefined }}
? settings.ics_enabled >
: room.icsEnabled, <Checkbox.HiddenInput />
icsFetchInterval: <Checkbox.Control>
settings.ics_fetch_interval !== undefined <Checkbox.Indicator />
? settings.ics_fetch_interval </Checkbox.Control>
: room.icsFetchInterval, <Checkbox.Label>
}); Automatically post transcription to Zulip
}} </Checkbox.Label>
isOwner={true} </Checkbox.Root>
/> </Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
</Tabs.Root>
</Dialog.Body> </Dialog.Body>
<Dialog.Footer> <Dialog.Footer>
<Button variant="ghost" onClick={handleCloseDialog}> <Button variant="ghost" onClick={handleCloseDialog}>

View File

@@ -710,3 +710,20 @@ export function useRoomIcsStatus(roomName: string | null) {
}, },
); );
} }
export function useRoomCalendarEvents(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings",
{
params: {
path: { room_name: roomName || "" },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}