feat: migrate from chakra 2 to chakra 3 (#500)

* feat: separate page into different component, greatly improving the loading and reactivity

* fix: various fixes

* feat: migrate to Chakra UI v3 - update theme, fix deprecated props

- Add whiteAlpha color palette with semantic tokens
- Update button recipe with fontWeight 600 and hover states
- Move Poppins font from theme to HTML tag className
- Fix deprecated props: isDisabled→disabled, align→alignItems/textAlign
- Remove button.css as styles are now handled by Chakra v3

* fix: complete Chakra UI v3 deprecated prop migrations

- Replace all isDisabled with disabled
- Replace all isChecked with checked
- Replace all isLoading with loading
- Replace all isOpen with open
- Replace all noOfLines with lineClamp
- Replace all align with alignItems on Flex/Stack components
- Replace all justify with justifyContent on Flex/Stack components
- Update temporary Select components to use new prop names
- Update REFACTOR2.md with completion status

* fix: add value prop to Menu.Item for proper hover states in Chakra v3

* fix: update browse page components for Chakra UI v3 compatibility

- Fix FilterSidebar status filter styling and prop usage
- Update Pagination component to use new Chakra v3 props and structure
- Refactor TranscriptTable to use modern Chakra patterns
- Clean up browse page layout and props
- Remove unused import from transcripts API view
- Enhance theme with additional semantic color tokens

* fix: polish browse page UI for Chakra v3

- Add rounded corners to FilterSidebar
- Adjust responsive breakpoints from md to lg for table/card view
- Add consistent font weights to table headers
- Improve card view typography and spacing
- Fix padding and margins for better mobile experience
- Remove unused table recipe from theme

* fix: padding

* fix: rework transcript page

* fix: more tidy layout for topic

* fix: share and privacy using chakra3 select

* fix: fix share and privacy select, now working, with closing dialog

* fix: complete Chakra UI v3 migration for share components and fix all TypeScript errors

- Refactor shareZulip.tsx to integrate modal content directly
- Replace react-select-search with Chakra UI v3 Select components using collection pattern
- Convert all Checkbox components to use v3 composable structure (Checkbox.Root, etc.)
- Fix Card components to use Card.Root and Card.Body
- Replace deprecated textColor prop with color prop
- Update Menu components to use v3 namespace pattern (Menu.Root, Menu.Trigger, etc.)
- Remove unused AlertDialog imports
- Fix useDisclosure hook changes (isOpen -> open)
- Replace UnorderedList with List.Root and ListItem with List.Item
- Fix Skeleton components by removing isLoaded prop and using conditional rendering
- Update Button variants to valid v3 options
- Fix Spinner props (remove thickness, speed, emptyColor)
- Update toast API to use custom toaster component
- Fix Progress components and FormControl to Field.Root
- Update Alert to use compound component pattern
- Remove shareModal.tsx file after integration

* fix: bring back topic list

* fix: normalize menu item

* fix: migrate rooms page to Chakra UI v3 pattern

- Updated layout to match browse page with Flex container and proper spacing
- Migrated add/edit room modal from custom HTML to Chakra UI v3 Dialog component
- Replaced all Select components with Chakra UI v3 Select using createListCollection
- Replaced FormControl/FormLabel/FormHelperText with Field.Root/Field.Label/Field.HelperText
- Removed inline styles and used Chakra props (mr={2} instead of style={{ marginRight: "8px" }})
- Fixed TypeScript interfaces removing OptionBase extension
- Fixed theme.ts accordion anatomy import issue

* refactor: convert rooms list to table view with responsive design

- Create RoomTable component for desktop view showing room details in columns
- Create RoomCards component for mobile/tablet responsive view
- Refactor RoomList to use table/card components based on screen size
- Display Zulip configuration, room size, and recording settings in table
- Remove unused RoomItem component
- Import Room type from API for proper typing

* refactor: extract RoomActionsMenu component to eliminate duplication

- Create RoomActionsMenu component for consistent room action menus
- Update RoomCards and RoomTable to use the new shared component
- Remove duplicated menu code from both components

* feat: add icons to TranscriptActionsMenu for consistency

- Add FaTrash icon for Delete action with red color
- Add FaArrowsRotate icon for Reprocess action
- Matches the pattern established in RoomActionsMenu

* refactor: update icons from Font Awesome to Lucide React

- Replace FaEllipsisVertical with LuMenu in menu triggers
- Replace FaLink with LuLink for copy URL buttons
- Replace FaPencil with LuPen for edit actions
- Replace FaTrash with LuTrash for delete actions
- Replace FaArrowsRotate with LuRotateCw for reprocess action
- Consistent icon library usage across all components

* refactor: little pass on the icons

* fix: lu icon

* fix: primary for button

* fix: recording page with mic selection

* fix: also fix duration

* fix: use combobox for share zulip

* fix: use proper theming for button, variant was not recognized

* fix: room actions menu

* fix: remove other variant primary left.
This commit is contained in:
2025-07-21 16:16:12 -06:00
committed by GitHub
parent d77b5611f8
commit 901a239952
66 changed files with 3061 additions and 2437 deletions

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react";
import FullscreenModal from "./fullsreenModal";
import AboutContent from "./aboutContent";
import { Button } from "@chakra-ui/react";
type AboutProps = {
buttonText: string;
@@ -12,12 +13,9 @@ export default function About({ buttonText }: AboutProps) {
return (
<>
<button
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
onClick={() => setModalOpen(true)}
>
<Button mt={2} onClick={() => setModalOpen(true)} variant="subtle">
{buttonText}
</button>
</Button>
{modalOpen && (
<FullscreenModal close={() => setModalOpen(false)}>
<AboutContent />

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react";
import FullscreenModal from "./fullsreenModal";
import PrivacyContent from "./privacyContent";
import { Button } from "@chakra-ui/react";
type PrivacyProps = {
buttonText: string;
@@ -12,12 +13,9 @@ export default function Privacy({ buttonText }: PrivacyProps) {
return (
<>
<button
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
onClick={() => setModalOpen(true)}
>
<Button mt={2} onClick={() => setModalOpen(true)} variant="subtle">
{buttonText}
</button>
</Button>
{modalOpen && (
<FullscreenModal close={() => setModalOpen(false)}>
<PrivacyContent />

View File

@@ -1,13 +1,6 @@
import React from "react";
import {
Button,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
} from "@chakra-ui/react";
import { Button } from "@chakra-ui/react";
// import { Dialog } from "@chakra-ui/react";
interface DeleteTranscriptDialogProps {
isOpen: boolean;
@@ -22,30 +15,34 @@ export default function DeleteTranscriptDialog({
onConfirm,
cancelRef,
}: DeleteTranscriptDialogProps) {
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
// Temporarily return null to fix import issues
return null;
/* return (
<Dialog.Root
open={isOpen}
onOpenChange={(e) => !e.open && onClose()}
initialFocusEl={() => cancelRef.current}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Delete Transcript
</AlertDialogHeader>
<AlertDialogBody>
</Dialog.Header>
<Dialog.Body>
Are you sure? You can't undo this action afterwards.
</AlertDialogBody>
<AlertDialogFooter>
</Dialog.Body>
<Dialog.Footer>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={onConfirm} ml={3}>
<Button colorPalette="red" onClick={onConfirm} ml={3}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
); */
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Box, Stack, Link, Heading, Divider } from "@chakra-ui/react";
import { Box, Stack, Link, Heading } from "@chakra-ui/react";
import NextLink from "next/link";
import { Room, SourceKind } from "../../../api";
@@ -20,24 +20,23 @@ export default function FilterSidebar({
const sharedRooms = rooms.filter((room) => room.is_shared);
return (
<Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100">
<Stack spacing={3}>
<Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100" rounded="md">
<Stack gap={3}>
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
>
All Transcripts
</Link>
<Divider />
<Box borderBottomWidth="1px" my={2} />
{myRooms.length > 0 && (
<>
<Heading size="sm">My Rooms</Heading>
<Heading size="md">My Rooms</Heading>
{myRooms.map((room) => (
<Link
@@ -50,7 +49,6 @@ export default function FilterSidebar({
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
@@ -66,7 +64,7 @@ export default function FilterSidebar({
{sharedRooms.length > 0 && (
<>
<Heading size="sm">Shared Rooms</Heading>
<Heading size="md">Shared Rooms</Heading>
{sharedRooms.map((room) => (
<Link
@@ -79,7 +77,6 @@ export default function FilterSidebar({
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
@@ -93,7 +90,7 @@ export default function FilterSidebar({
</>
)}
<Divider />
<Box borderBottomWidth="1px" my={2} />
<Link
as={NextLink}
href="#"

View File

@@ -0,0 +1,47 @@
import React from "react";
import { Pagination, IconButton, ButtonGroup } from "@chakra-ui/react";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
type PaginationProps = {
page: number;
setPage: (page: number) => void;
total: number;
size: number;
};
export default function PaginationComponent(props: PaginationProps) {
const { page, setPage, total, size } = props;
const totalPages = Math.ceil(total / size);
if (totalPages <= 1) return null;
return (
<Pagination.Root
count={total}
pageSize={size}
page={page}
onPageChange={(details) => setPage(details.page)}
style={{ display: "flex", justifyContent: "center" }}
>
<ButtonGroup variant="ghost" size="xs">
<Pagination.PrevTrigger asChild>
<IconButton>
<LuChevronLeft />
</IconButton>
</Pagination.PrevTrigger>
<Pagination.Items
render={(page) => (
<IconButton variant={{ base: "ghost", _selected: "solid" }}>
{page.value}
</IconButton>
)}
/>
<Pagination.NextTrigger asChild>
<IconButton>
<LuChevronRight />
</IconButton>
</Pagination.NextTrigger>
</ButtonGroup>
</Pagination.Root>
);
}

View File

@@ -19,7 +19,7 @@ export default function SearchBar({ onSearch }: SearchBarProps) {
};
return (
<Flex mb={4} alignItems="center">
<Flex alignItems="center">
<Input
placeholder="Search transcriptions..."
value={searchInputValue}

View File

@@ -1,13 +1,6 @@
import React from "react";
import {
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Icon,
} from "@chakra-ui/react";
import { FaEllipsisVertical } from "react-icons/fa6";
import { IconButton, Icon, Menu } from "@chakra-ui/react";
import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu";
interface TranscriptActionsMenuProps {
transcriptId: string;
@@ -21,19 +14,25 @@ export default function TranscriptActionsMenu({
onReprocess,
}: TranscriptActionsMenuProps) {
return (
<Menu closeOnSelect={true} isLazy={true}>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={(e) => onDelete(transcriptId)(e)}>Delete</MenuItem>
<MenuItem onClick={(e) => onReprocess(transcriptId)(e)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
<Menu.Root closeOnSelect={true} lazyMount={true}>
<Menu.Trigger asChild>
<IconButton aria-label="Options" size="sm" variant="ghost">
<LuMenu />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="reprocess"
onClick={(e) => onReprocess(transcriptId)(e)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
<Menu.Item value="delete" onClick={(e) => onDelete(transcriptId)(e)}>
<LuTrash /> Delete
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
}

View File

@@ -20,7 +20,7 @@ export default function TranscriptCards({
loading,
}: TranscriptCardsProps) {
return (
<Box display={{ base: "block", md: "none" }} position="relative">
<Box display={{ base: "block", lg: "none" }} position="relative">
{loading && (
<Flex
position="absolute"
@@ -33,7 +33,7 @@ export default function TranscriptCards({
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" thickness="4px" />
<Spinner size="xl" color="gray.700" />
</Flex>
)}
<Box
@@ -41,9 +41,15 @@ export default function TranscriptCards({
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Stack spacing={2}>
<Stack gap={2}>
{transcripts.map((item) => (
<Box key={item.id} borderWidth={1} p={4} borderRadius="md">
<Box
key={item.id}
borderWidth={1}
p={4}
borderRadius="md"
fontSize="sm"
>
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
<TranscriptStatusIcon status={item.status} />
@@ -52,7 +58,7 @@ export default function TranscriptCards({
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
fontWeight="bold"
fontWeight="600"
display="block"
>
{item.title || "Unnamed Transcript"}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Icon, Tooltip } from "@chakra-ui/react";
import { Icon, Box } from "@chakra-ui/react";
import {
FaCheck,
FaTrash,
@@ -18,43 +18,33 @@ export default function TranscriptStatusIcon({
switch (status) {
case "ended":
return (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
<Box as="span" title="Processing done">
<Icon color="green" as={FaCheck} />
</Box>
);
case "error":
return (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
<Box as="span" title="Processing error">
<Icon color="red.500" as={FaTrash} />
</Box>
);
case "idle":
return (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
<Box as="span" title="New meeting, no recording">
<Icon color="yellow.500" as={FaStar} />
</Box>
);
case "processing":
return (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
<Box as="span" title="Processing in progress">
<Icon color="gray.500" as={FaGear} />
</Box>
);
case "recording":
return (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
<Box as="span" title="Recording in progress">
<Icon color="blue.500" as={FaMicrophone} />
</Box>
);
default:
return null;

View File

@@ -1,16 +1,5 @@
import React from "react";
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Link,
Flex,
Spinner,
} from "@chakra-ui/react";
import { Box, Table, Link, Flex, Spinner } from "@chakra-ui/react";
import NextLink from "next/link";
import { GetTranscriptMinimal } from "../../../api";
import { formatTimeMs, formatLocalDate } from "../../../lib/time";
@@ -31,7 +20,7 @@ export default function TranscriptTable({
loading,
}: TranscriptTableProps) {
return (
<Box display={{ base: "none", md: "block" }} position="relative">
<Box display={{ base: "none", lg: "block" }} position="relative">
{loading && (
<Flex
position="absolute"
@@ -39,12 +28,10 @@ export default function TranscriptTable({
left={0}
right={0}
bottom={0}
bg="rgba(255, 255, 255, 0.8)"
zIndex={10}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" thickness="4px" />
<Spinner size="xl" color="gray.700" />
</Flex>
)}
<Box
@@ -52,47 +39,60 @@ export default function TranscriptTable({
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Table colorScheme="gray">
<Thead>
<Tr>
<Th pl={12} width="400px">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeader
width="16px"
fontWeight="600"
></Table.ColumnHeader>
<Table.ColumnHeader width="400px" fontWeight="600">
Transcription Title
</Th>
<Th width="150px">Source</Th>
<Th width="200px">Date</Th>
<Th width="100px">Duration</Th>
<Th width="50px"></Th>
</Tr>
</Thead>
<Tbody>
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Source
</Table.ColumnHeader>
<Table.ColumnHeader width="200px" fontWeight="600">
Date
</Table.ColumnHeader>
<Table.ColumnHeader width="100px" fontWeight="600">
Duration
</Table.ColumnHeader>
<Table.ColumnHeader
width="50px"
fontWeight="600"
></Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{transcripts.map((item) => (
<Tr key={item.id}>
<Td>
<Flex alignItems="start">
<TranscriptStatusIcon status={item.status} />
<Link as={NextLink} href={`/transcripts/${item.id}`} ml={2}>
{item.title || "Unnamed Transcript"}
</Link>
</Flex>
</Td>
<Td>
<Table.Row key={item.id}>
<Table.Cell>
<TranscriptStatusIcon status={item.status} />
</Table.Cell>
<Table.Cell>
<Link as={NextLink} href={`/transcripts/${item.id}`}>
{item.title || "Unnamed Transcript"}
</Link>
</Table.Cell>
<Table.Cell>
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Td>
<Td>{formatLocalDate(item.created_at)}</Td>
<Td>{formatTimeMs(item.duration)}</Td>
<Td>
</Table.Cell>
<Table.Cell>{formatLocalDate(item.created_at)}</Table.Cell>
<Table.Cell>{formatTimeMs(item.duration)}</Table.Cell>
<Table.Cell>
<TranscriptActionsMenu
transcriptId={item.id}
onDelete={onDelete}
onReprocess={onReprocess}
/>
</Td>
</Tr>
</Table.Cell>
</Table.Row>
))}
</Tbody>
</Table>
</Table.Body>
</Table.Root>
</Box>
</Box>
);

View File

@@ -4,7 +4,7 @@ import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react";
import useTranscriptList from "../transcripts/useTranscriptList";
import useSessionUser from "../../lib/useSessionUser";
import { Room } from "../../api";
import Pagination from "./pagination";
import Pagination from "./_components/Pagination";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { SourceKind } from "../../api";
@@ -40,10 +40,6 @@ export default function TranscriptBrowser() {
setDeletedItemIds([]);
}, [page, response]);
useEffect(() => {
refetch();
}, [selectedRoomId, page, searchTerm]);
useEffect(() => {
if (!api) return;
api
@@ -59,7 +55,6 @@ export default function TranscriptBrowser() {
setSelectedSourceKind(sourceKind);
setSelectedRoomId(roomId);
setPage(1);
refetch();
};
const handleSearch = (searchTerm: string) => {
@@ -67,19 +62,28 @@ export default function TranscriptBrowser() {
setSearchTerm(searchTerm);
setSelectedSourceKind(null);
setSelectedRoomId("");
refetch();
};
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Spinner size="xl" />
</Flex>
);
if (!loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Text>
No transcripts found, but you can&nbsp;
<Link href="/transcripts/new" className="underline">
@@ -138,10 +142,15 @@ export default function TranscriptBrowser() {
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
p={4}
pt={4}
>
<Flex flexDir="row" justify="space-between" align="center" mb={4}>
<Heading size="md">
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={4}
>
<Heading size="lg">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{loading || (deletionLoading && <Spinner size="sm" />)}
</Heading>
@@ -155,7 +164,14 @@ export default function TranscriptBrowser() {
onFilterChange={handleFilterTranscripts}
/>
<Flex flexDir="column" flex="1" p={4} gap={4}>
<Flex
flexDir="column"
flex="1"
pt={{ base: 4, md: 0 }}
pb={4}
gap={4}
px={{ base: 0, md: 4 }}
>
<SearchBar onSearch={handleSearch} />
<Pagination
page={page}

View File

@@ -1,79 +0,0 @@
import { Button, Flex, IconButton } from "@chakra-ui/react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
type PaginationProps = {
page: number;
setPage: (page: number) => void;
total: number;
size: number;
};
export default function Pagination(props: PaginationProps) {
const { page, setPage, total, size } = props;
const totalPages = Math.ceil(total / size);
const pageNumbers = Array.from(
{ length: totalPages },
(_, i) => i + 1,
).filter((pageNumber) => {
if (totalPages <= 3) {
// If there are 3 or fewer total pages, show all pages.
return true;
} else if (page <= 2) {
// For the first two pages, show the first 3 pages.
return pageNumber <= 3;
} else if (page >= totalPages - 1) {
// For the last two pages, show the last 3 pages.
return pageNumber >= totalPages - 2;
} else {
// For all other cases, show 3 pages centered around the current page.
return pageNumber >= page - 1 && pageNumber <= page + 1;
}
});
const canGoPrevious = page > 1;
const canGoNext = page < totalPages;
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
}
};
return (
<Flex justify="center" align="center" gap="2" mx="2">
<IconButton
isRound={true}
variant="text"
color={!canGoPrevious ? "gray" : "dark"}
mb="1"
icon={<FaChevronLeft />}
onClick={() => handlePageChange(page - 1)}
disabled={!canGoPrevious}
aria-label="Previous page"
/>
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="text"
color={page === pageNumber ? "gray" : "dark"}
onClick={() => handlePageChange(pageNumber)}
disabled={page === pageNumber}
>
{pageNumber}
</Button>
))}
<IconButton
isRound={true}
variant="text"
color={!canGoNext ? "gray" : "dark"}
icon={<FaChevronRight />}
mb="1"
onClick={() => handlePageChange(page + 1)}
disabled={!canGoNext}
aria-label="Next page"
/>
</Flex>
);
}

View File

@@ -1,4 +1,4 @@
import { Container, Flex, Link } from "@chakra-ui/layout";
import { Container, Flex, Link } from "@chakra-ui/react";
import { getConfig } from "../lib/edgeConfig";
import NextLink from "next/link";
import Image from "next/image";
@@ -61,12 +61,7 @@ export default async function AppLayout({
{browse ? (
<>
&nbsp;·&nbsp;
<Link
href="/browse"
as={NextLink}
className="font-light px-2"
prefetch={false}
>
<Link href="/browse" as={NextLink} className="font-light px-2">
Browse
</Link>
</>
@@ -76,12 +71,7 @@ export default async function AppLayout({
{rooms ? (
<>
&nbsp;·&nbsp;
<Link
href="/rooms"
as={NextLink}
className="font-light px-2"
prefetch={false}
>
<Link href="/rooms" as={NextLink} className="font-light px-2">
Rooms
</Link>
</>

View File

@@ -0,0 +1,37 @@
import React from "react";
import { IconButton, Menu } from "@chakra-ui/react";
import { LuMenu, LuPen, LuTrash } from "react-icons/lu";
interface RoomActionsMenuProps {
roomId: string;
roomData: any;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
}
export function RoomActionsMenu({
roomId,
roomData,
onEdit,
onDelete,
}: RoomActionsMenuProps) {
return (
<Menu.Root closeOnSelect={true} lazyMount={true}>
<Menu.Trigger asChild>
<IconButton aria-label="actions">
<LuMenu />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item value="edit" onClick={() => onEdit(roomId, roomData)}>
<LuPen /> Edit
</Menu.Item>
<Menu.Item value="delete" onClick={() => onDelete(roomId)}>
<LuTrash /> Delete
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
}

View File

@@ -0,0 +1,126 @@
import React from "react";
import {
Box,
Card,
Flex,
Heading,
IconButton,
Link,
Spacer,
Text,
VStack,
HStack,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { Room } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomCardsProps {
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
}
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";
case "prompt":
return "Cloud (Prompt)";
case "automatic-2nd-participant":
return "Cloud (Auto)";
default:
return `Cloud`;
}
}
return type;
};
export function RoomCards({
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
}: RoomCardsProps) {
return (
<Box display={{ base: "block", lg: "none" }}>
<VStack gap={3} align="stretch">
{rooms.map((room) => (
<Card.Root key={room.id} size="sm">
<Card.Body>
<Flex alignItems="center" mt={-2}>
<Heading size="sm">
<Link href={`/${room.name}`}>{room.name}</Link>
</Heading>
<Spacer />
{linkCopied === room.name ? (
<Text color="green.500" mr={2} fontSize="sm">
Copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
onClick={() => onCopyUrl(room.name)}
mr={2}
size="sm"
variant="ghost"
>
<LuLink />
</IconButton>
)}
<RoomActionsMenu
roomId={room.id}
roomData={room}
onEdit={onEdit}
onDelete={onDelete}
/>
</Flex>
<VStack align="start" fontSize="sm" gap={0}>
{room.zulip_auto_post && (
<HStack gap={2}>
<Text fontWeight="500">Zulip:</Text>
<Text>
{room.zulip_stream && room.zulip_topic
? `${room.zulip_stream} > ${room.zulip_topic}`
: room.zulip_stream || "Enabled"}
</Text>
</HStack>
)}
<HStack gap={2}>
<Text fontWeight="500">Size:</Text>
<Text>{getRoomModeDisplay(room.room_mode)}</Text>
</HStack>
<HStack gap={2}>
<Text fontWeight="500">Recording:</Text>
<Text>
{getRecordingDisplay(
room.recording_type,
room.recording_trigger,
)}
</Text>
</HStack>
</VStack>
</Card.Body>
</Card.Root>
))}
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
import { Room } from "../../../api";
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
interface RoomListProps {
title: string;
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
emptyMessage?: string;
mb?: number | string;
pt?: number | string;
loading?: boolean;
}
export function RoomList({
title,
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
emptyMessage = "No rooms found",
mb,
pt,
loading,
}: RoomListProps) {
return (
<VStack alignItems="start" gap={4} mb={mb} pt={pt}>
<Heading size="md">{title}</Heading>
{rooms.length > 0 ? (
<Box w="full">
<RoomTable
rooms={rooms}
linkCopied={linkCopied}
onCopyUrl={onCopyUrl}
onEdit={onEdit}
onDelete={onDelete}
loading={loading}
/>
<RoomCards
rooms={rooms}
linkCopied={linkCopied}
onCopyUrl={onCopyUrl}
onEdit={onEdit}
onDelete={onDelete}
/>
</Box>
) : (
<Text>{emptyMessage}</Text>
)}
</VStack>
);
}

View File

@@ -0,0 +1,164 @@
import React from "react";
import {
Box,
Table,
Link,
Flex,
IconButton,
Text,
Spinner,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { Room } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomTableProps {
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => 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";
};
export function RoomTable({
rooms,
linkCopied,
onCopyUrl,
onEdit,
onDelete,
loading,
}: RoomTableProps) {
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="250px" fontWeight="600">
Zulip
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Room Size
</Table.ColumnHeader>
<Table.ColumnHeader width="200px" 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>
{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}>
{linkCopied === room.name ? (
<Text color="green.500" fontSize="sm">
Copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
onClick={() => onCopyUrl(room.name)}
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>
);
}

View File

@@ -2,61 +2,43 @@
import {
Button,
Card,
CardBody,
Checkbox,
CloseButton,
Dialog,
Field,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Select,
Spinner,
createListCollection,
useDisclosure,
VStack,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Checkbox,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { Container } from "@chakra-ui/react";
import { FaEllipsisVertical, FaTrash, FaPencil, FaLink } from "react-icons/fa6";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { Select, Options, OptionBase } from "chakra-react-select";
import { ApiError } from "../../api";
import { ApiError, Room } from "../../api";
import { RoomList } from "./_components/RoomList";
interface SelectOption extends OptionBase {
interface SelectOption {
label: string;
value: string;
}
const RESERVED_PATHS = ["browse", "rooms", "transcripts"];
const roomModeOptions: Options<SelectOption> = [
const roomModeOptions: SelectOption[] = [
{ label: "2-4 people", value: "normal" },
{ label: "2-200 people", value: "group" },
];
const recordingTriggerOptions: Options<SelectOption> = [
const recordingTriggerOptions: SelectOption[] = [
{ label: "None", value: "none" },
{ label: "Prompt", value: "prompt" },
{ label: "Automatic", value: "automatic-2nd-participant" },
];
const recordingTypeOptions: Options<SelectOption> = [
const recordingTypeOptions: SelectOption[] = [
{ label: "None", value: "none" },
{ label: "Local", value: "local" },
{ label: "Cloud", value: "cloud" },
@@ -75,7 +57,20 @@ const roomInitialState = {
};
export default function RoomsList() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { open, onOpen, onClose } = useDisclosure();
// Create collections for Select components
const roomModeCollection = createListCollection({
items: roomModeOptions,
});
const recordingTriggerCollection = createListCollection({
items: recordingTriggerOptions,
});
const recordingTypeCollection = createListCollection({
items: recordingTypeOptions,
});
const [room, setRoom] = useState(roomInitialState);
const [isEditing, setIsEditing] = useState(false);
const [editRoomId, setEditRoomId] = useState("");
@@ -131,15 +126,23 @@ export default function RoomsList() {
fetchZulipTopics();
}, [room.zulipStream, streams, api]);
const streamOptions: Options<SelectOption> = streams.map((stream) => {
const streamOptions: SelectOption[] = streams.map((stream) => {
return { label: stream.name, value: stream.name };
});
const topicOptions: Options<SelectOption> = topics.map((topic) => ({
const topicOptions: SelectOption[] = topics.map((topic) => ({
label: topic.name,
value: topic.name,
}));
const streamCollection = createListCollection({
items: streamOptions,
});
const topicCollection = createListCollection({
items: topicOptions,
});
const handleCopyUrl = (roomName: string) => {
const roomUrl = `${window.location.origin}/${roomName}`;
navigator.clipboard.writeText(roomUrl);
@@ -245,312 +248,350 @@ export default function RoomsList() {
});
};
const myRooms =
const myRooms: Room[] =
response?.items.filter((roomData) => !roomData.is_shared) || [];
const sharedRooms =
const sharedRooms: Room[] =
response?.items.filter((roomData) => roomData.is_shared) || [];
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Flex
flexDir="column"
alignItems="center"
justifyContent="center"
h="100%"
>
<Spinner size="xl" />
</Flex>
);
return (
<>
<Container maxW={"container.lg"}>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
mb={2}
<Flex
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
pt={2}
>
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={4}
>
<Heading size="lg">Rooms {loading && <Spinner size="sm" />}</Heading>
<Button
colorPalette="primary"
onClick={() => {
setIsEditing(false);
setRoom(roomInitialState);
setNameError("");
onOpen();
}}
>
<Heading>Rooms</Heading>
<Spacer />
<Button
colorScheme="blue"
onClick={() => {
setIsEditing(false);
setRoom(roomInitialState);
setNameError("");
onOpen();
}}
>
Add Room
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{isEditing ? "Edit Room" : "Add Room"}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<FormLabel>Room name</FormLabel>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<FormHelperText>
No spaces or special characters allowed
</FormHelperText>
{nameError && <Text color="red.500">{nameError}</Text>}
</FormControl>
Add Room
</Button>
</Flex>
<FormControl mt={4}>
<Checkbox
name="isLocked"
isChecked={room.isLocked}
onChange={handleRoomChange}
>
Locked room
</Checkbox>
</FormControl>
<FormControl mt={4}>
<FormLabel>Room size</FormLabel>
<Select
name="roomMode"
options={roomModeOptions}
value={{
label: roomModeOptions.find(
(rm) => rm.value === room.roomMode,
)?.label,
value: room.roomMode,
}}
onChange={(newValue) =>
setRoom({
...room,
roomMode: newValue!.value,
})
}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Recording type</FormLabel>
<Select
name="recordingType"
options={recordingTypeOptions}
value={{
label: recordingTypeOptions.find(
(rt) => rt.value === room.recordingType,
)?.label,
value: room.recordingType,
}}
onChange={(newValue) =>
setRoom({
...room,
recordingType: newValue!.value,
recordingTrigger:
newValue!.value !== "cloud"
? "none"
: room.recordingTrigger,
})
}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Cloud recording start trigger</FormLabel>
<Select
name="recordingTrigger"
options={recordingTriggerOptions}
value={{
label: recordingTriggerOptions.find(
(rt) => rt.value === room.recordingTrigger,
)?.label,
value: room.recordingTrigger,
}}
onChange={(newValue) =>
setRoom({
...room,
recordingTrigger: newValue!.value,
})
}
isDisabled={room.recordingType !== "cloud"}
/>
</FormControl>
<FormControl mt={8}>
<Checkbox
name="zulipAutoPost"
isChecked={room.zulipAutoPost}
onChange={handleRoomChange}
>
Automatically post transcription to Zulip
</Checkbox>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip stream</FormLabel>
<Select
name="zulipStream"
options={streamOptions}
placeholder="Select stream"
value={{ label: room.zulipStream, value: room.zulipStream }}
onChange={(newValue) =>
setRoom({
...room,
zulipStream: newValue!.value,
zulipTopic: "",
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<FormLabel>Zulip topic</FormLabel>
<Select
name="zulipTopic"
options={topicOptions}
placeholder="Select topic"
value={{ label: room.zulipTopic, value: room.zulipTopic }}
onChange={(newValue) =>
setRoom({
...room,
zulipTopic: newValue!.value,
})
}
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<Checkbox
name="isShared"
isChecked={room.isShared}
onChange={handleRoomChange}
>
Shared room
</Checkbox>
</FormControl>
</ModalBody>
<Dialog.Root
open={open}
onOpenChange={(e) => (e.open ? onOpen() : onClose())}
size="lg"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{isEditing ? "Edit Room" : "Add Room"}
</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && <Field.ErrorText>{nameError}</Field.ErrorText>}
</Field.Root>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
</Button>
<Button
colorScheme="blue"
onClick={handleSaveRoom}
isDisabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}
<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);
}}
>
{isEditing ? "Save" : "Add"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Flex>
<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) =>
setRoom({ ...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) =>
setRoom({
...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) =>
setRoom({ ...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) =>
setRoom({
...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) =>
setRoom({ ...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>
<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>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
colorPalette="primary"
onClick={handleSaveRoom}
disabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}
>
{isEditing ? "Save" : "Add"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
<VStack align="start" mb={10} pt={4} gap={4}>
<Heading size="md">My Rooms</Heading>
{myRooms.length > 0 ? (
myRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
{linkCopied === roomData.name ? (
<Text mr={2} color="green.500">
Link copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
icon={<FaLink />}
onClick={() => handleCopyUrl(roomData.name)}
mr={2}
/>
)}
<RoomList
title="My Rooms"
rooms={myRooms}
linkCopied={linkCopied}
onCopyUrl={handleCopyUrl}
onEdit={handleEditRoom}
onDelete={handleDeleteRoom}
emptyMessage="No rooms found"
/>
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Text>No rooms found</Text>
)}
</VStack>
<VStack align="start" pt={4} gap={4}>
<Heading size="md">Shared Rooms</Heading>
{sharedRooms.length > 0 ? (
sharedRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
{linkCopied === roomData.name ? (
<Text mr={2} color="green.500">
Link copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
icon={<FaLink />}
onClick={() => handleCopyUrl(roomData.name)}
mr={2}
/>
)}
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Text>No shared rooms found</Text>
)}
</VStack>
</Container>
</>
<RoomList
title="Shared Rooms"
rooms={sharedRooms}
linkCopied={linkCopied}
onCopyUrl={handleCopyUrl}
onEdit={handleEditRoom}
onDelete={handleDeleteRoom}
emptyMessage="No shared rooms found"
pt={4}
/>
</Flex>
);
}

View File

@@ -0,0 +1,61 @@
import { Box, Text, Accordion, Flex } from "@chakra-ui/react";
import { formatTime } from "../../../../lib/time";
import { Topic } from "../../webSocketTypes";
import { TopicSegment } from "./TopicSegment";
interface TopicItemProps {
topic: Topic;
isActive: boolean;
getSpeakerName: (speakerNumber: number) => string | undefined;
}
export function TopicItem({ topic, isActive, getSpeakerName }: TopicItemProps) {
return (
<Accordion.Item value={topic.id} id={`topic-${topic.id}`}>
<Accordion.ItemTrigger
background={isActive ? "gray.50" : "white"}
display="flex"
alignItems="start"
justifyContent="space-between"
>
<Flex
display="flex"
justifyContent="center"
alignItems="center"
height="24px"
width="24px"
>
<Accordion.ItemIndicator />
</Flex>
<Box flex="1">{topic.title} </Box>
<Text as="span" color="gray.500" fontSize="xs" pr={1}>
{formatTime(topic.timestamp)}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody p={4}>
{isActive && (
<>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<TopicSegment
key={index}
segment={segment}
speakerName={
getSpeakerName(segment.speaker) ||
`Speaker ${segment.speaker}`
}
/>
))}
</>
) : (
<>{topic.transcript}</>
)}
</>
)}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
);
}

View File

@@ -1,20 +1,10 @@
import React, { useState, useEffect } from "react";
import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Text,
} from "@chakra-ui/react";
import { featureEnabled } from "../../domainContext";
import ScrollToBottom from "../../scrollToBottom";
import { Topic } from "../../webSocketTypes";
import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
type TopicListProps = {
topics: Topic[];
@@ -41,9 +31,7 @@ export function TopicList({
const participants = useParticipants(transcriptId);
const scrollToTopic = () => {
const topicDiv = document.getElementById(
`accordion-button-topic-${activeTopic?.id}`,
);
const topicDiv = document.getElementById(`topic-${activeTopic?.id}`);
setTimeout(() => {
topicDiv?.scrollIntoView({
@@ -133,88 +121,29 @@ export function TopicList({
h={"100%"}
onScroll={handleScroll}
width="full"
padding={2}
>
{topics.length > 0 && (
<Accordion
index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
variant="custom"
allowToggle
<Accordion.Root
multiple={false}
collapsible={true}
value={activeTopic ? [activeTopic.id] : []}
onValueChange={(details) => {
const selectedTopicId = details.value[0];
const selectedTopic = selectedTopicId
? topics.find((t) => t.id === selectedTopicId)
: null;
setActiveTopic(selectedTopic || null);
}}
>
{topics.map((topic, index) => (
<AccordionItem
key={index}
background={{
base: "light",
hover: "gray.100",
focus: "gray.100",
}}
id={`topic-${topic.id}`}
>
<Flex dir="row" letterSpacing={".2"}>
<AccordionButton
onClick={() => {
setActiveTopic(
activeTopic?.id == topic.id ? null : topic,
);
}}
>
<AccordionIcon />
<Box as="span" textAlign="left" ml="1">
{topic.title}{" "}
<Text
as="span"
color="gray.500"
fontSize="sm"
fontWeight="bold"
>
&nbsp;[{formatTime(topic.timestamp)}]&nbsp;-&nbsp;[
{formatTime(topic.timestamp + (topic.duration || 0))}]
</Text>
</Box>
</AccordionButton>
</Flex>
<AccordionPanel>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<Text
key={index}
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight={"1.3"}
>
<Text
as="span"
color={"gray.500"}
fontFamily={"monospace"}
fontSize={"sm"}
>
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight={"bold"}
fontSize={"sm"}
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{getSpeakerName(segment.speaker)}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
))}
</>
) : (
<>{topic.transcript}</>
)}
</AccordionPanel>
</AccordionItem>
{topics.map((topic) => (
<TopicItem
key={topic.id}
topic={topic}
isActive={activeTopic?.id === topic.id}
getSpeakerName={getSpeakerName}
/>
))}
</Accordion>
</Accordion.Root>
)}
{status == "recording" && (
@@ -225,7 +154,7 @@ export function TopicList({
{(status == "recording" || status == "idle") &&
currentTranscriptText.length == 0 &&
topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>
Full discussion transcript will appear here after you start
recording.
@@ -236,7 +165,7 @@ export function TopicList({
</Box>
)}
{status == "processing" && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>We are processing the recording, please wait.</Text>
{!requireLogin && (
<span>
@@ -246,12 +175,12 @@ export function TopicList({
</Box>
)}
{status == "ended" && topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>Recording has ended without topics being found.</Text>
</Box>
)}
{status == "error" && (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>There was an error processing your recording</Text>
</Box>
)}

View File

@@ -0,0 +1,39 @@
import { Text } from "@chakra-ui/react";
import { formatTime } from "../../../../lib/time";
import { generateHighContrastColor } from "../../../../lib/utils";
interface TopicSegmentProps {
segment: {
start: number;
speaker: number;
text: string;
};
speakerName: string;
}
export function TopicSegment({ segment, speakerName }: TopicSegmentProps) {
return (
<Text
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight="1.3"
>
<Text as="span" color="gray.500" fontFamily="monospace" fontSize="sm">
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight="bold"
fontSize="sm"
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{speakerName}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
);
}

View File

@@ -0,0 +1,3 @@
export { TopicList } from "./TopicList";
export { TopicItem } from "./TopicItem";
export { TopicSegment } from "./TopicSegment";

View File

@@ -11,11 +11,10 @@ import {
Button,
Flex,
Text,
UnorderedList,
List,
Input,
Kbd,
Spinner,
ListItem,
Grid,
} from "@chakra-ui/react";
@@ -351,7 +350,7 @@ const ParticipantList = ({
/>
<Button
onClick={doAction}
colorScheme="blue"
colorPalette="blue"
disabled={!action || anyLoading}
>
{!anyLoading ? (
@@ -371,14 +370,14 @@ const ParticipantList = ({
</Flex>
{participants.response && (
<UnorderedList
<List.Root
mx="0"
mb={{ base: 2, md: 4 }}
maxH="100%"
overflow="scroll"
>
{participants.response.map((participant: Participant) => (
<ListItem
<List.Item
onClick={selectParticipant(participant)}
cursor="pointer"
className={
@@ -410,7 +409,7 @@ const ParticipantList = ({
!loading && (
<Button
onClick={mergeSpeaker(selectedText, participant)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
@@ -435,7 +434,7 @@ const ParticipantList = ({
{selectedTextIsTimeSlice(selectedText) && !loading && (
<Button
onClick={assignTo(participant)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
@@ -460,16 +459,16 @@ const ParticipantList = ({
<Button
onClick={deleteParticipant(participant.id)}
colorScheme="blue"
colorPalette="blue"
ml="2"
size="sm"
>
Delete
</Button>
</Box>
</ListItem>
</List.Item>
))}
</UnorderedList>
</List.Root>
)}
</Grid>
</Box>

View File

@@ -11,7 +11,7 @@ import {
SkeletonCircle,
Flex,
} from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { ChevronLeft, ChevronRight } from "lucide-react";
type TopicHeader = {
stateCurrentTopic: [
@@ -91,73 +91,62 @@ export default function TopicHeader({
justifyContent="space-between"
{...chakraProps}
>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
{isLoaded ? (
<Circle
as="button"
onClick={onPrev}
disabled={!canGoPrevious}
onClick={canGoPrevious ? onPrev : undefined}
size="40px"
border="1px"
color={canGoPrevious ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
cursor={canGoPrevious ? "pointer" : "not-allowed"}
opacity={canGoPrevious ? 1 : 0.5}
>
{canGoPrevious ? (
<Kbd>
<ChevronLeftIcon />
<ChevronLeft size={16} />
</Kbd>
) : (
<ChevronLeftIcon />
<ChevronLeft size={16} />
)}
</Circle>
</SkeletonCircle>
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
flexGrow={1}
mx={6}
>
<Flex wrap="nowrap" justifyContent="center">
<Heading size="lg" textAlign="center" noOfLines={1}>
) : (
<SkeletonCircle h="40px" w="40px" mb="2" />
)}
{isLoaded ? (
<Flex wrap="nowrap" justifyContent="center" flexGrow={1} mx={6}>
<Heading size="lg" textAlign="center" lineClamp={1}>
{currentTopic?.title}{" "}
</Heading>
<Heading size="lg" ml="3">
{(number || 0) + 1}/{total}
</Heading>
</Flex>
</Skeleton>
<SkeletonCircle
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
w={isLoaded ? "auto" : "40px"}
mb="2"
fadeDuration={1}
>
) : (
<Skeleton h="40px" mb="2" flexGrow={1} mx={6} />
)}
{isLoaded ? (
<Circle
as="button"
onClick={onNext}
disabled={!canGoNext}
onClick={canGoNext ? onNext : undefined}
size="40px"
border="1px"
color={canGoNext ? "inherit" : "gray"}
borderColor={canGoNext ? "body-text" : "gray"}
cursor={canGoNext ? "pointer" : "not-allowed"}
opacity={canGoNext ? 1 : 0.5}
>
{canGoNext ? (
<Kbd>
<ChevronRightIcon />
<ChevronRight size={16} />
</Kbd>
) : (
<ChevronRightIcon />
<ChevronRight size={16} />
)}
</Circle>
</SkeletonCircle>
) : (
<SkeletonCircle h="40px" w="40px" mb="2" />
)}
</Box>
);
}

View File

@@ -199,61 +199,54 @@ const TopicPlayer = ({
</Text>
);
}
return (
<Skeleton
isLoaded={isLoaded}
h={isLoaded ? "auto" : "40px"}
fadeDuration={1}
w={isLoaded ? "auto" : "container.md"}
margin="auto"
{...chakraProps}
>
<Wrap spacing="4" justify="center" align="center">
<WrapItem>
<SoundWaveCss playing={isPlaying} />
<Text fontSize="sm" pt="1" pl="2">
{showTime}
</Text>
</WrapItem>
<WrapItem>
<Button onClick={playTopic} colorScheme="blue">
Play from start
</Button>
</WrapItem>
<WrapItem>
{!isPlaying ? (
<Button
onClick={playCurrent}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Play
</Button>
) : (
<Button
onClick={pause}
ref={playButton}
id="playButton"
colorScheme="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Pause
</Button>
)}
</WrapItem>
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
return isLoaded ? (
<Wrap gap="4" justify="center" align="center">
<WrapItem>
<SoundWaveCss playing={isPlaying} />
<Text fontSize="sm" pt="1" pl="2">
{showTime}
</Text>
</WrapItem>
<WrapItem>
<Button onClick={playTopic} colorPalette="blue">
Play from start
</Button>
</WrapItem>
<WrapItem>
{!isPlaying ? (
<Button
disabled={!selectedTime}
onClick={playSelection}
colorScheme="blue"
onClick={playCurrent}
ref={playButton}
id="playButton"
colorPalette="blue"
w="120px"
>
<Kbd color="blue.600">,</Kbd>&nbsp;Play selection
<Kbd color="blue.600">Space</Kbd>&nbsp;Play
</Button>
</WrapItem>
</Wrap>
</Skeleton>
) : (
<Button
onClick={pause}
ref={playButton}
id="playButton"
colorPalette="blue"
w="120px"
>
<Kbd color="blue.600">Space</Kbd>&nbsp;Pause
</Button>
)}
</WrapItem>
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
<Button
disabled={!selectedTime}
onClick={playSelection}
colorPalette="blue"
>
<Kbd color="blue.600">,</Kbd>&nbsp;Play selection
</Button>
</WrapItem>
</Wrap>
) : (
<Skeleton h="40px" w="container.md" margin="auto" {...chakraProps} />
);
};

View File

@@ -16,7 +16,7 @@ import {
Textarea,
Spacer,
} from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { LuPen } from "react-icons/lu";
import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
@@ -108,29 +108,26 @@ export default function FinalSummary(props: FinalSummaryProps) {
right="0"
>
{isEditMode && (
<>
<Flex gap={2} align="center" w="full">
<Heading size={{ base: "md" }}>Summary</Heading>
<Spacer />
<Button
onClick={onDiscardClick}
colorScheme="gray"
variant={"text"}
>
Discard
<Button onClick={onDiscardClick} variant="ghost">
Cancel
</Button>
<Button onClick={onSaveClick} colorScheme="blue">
Save
</Button>
</>
<Button onClick={onSaveClick}>Save</Button>
</Flex>
)}
{!isEditMode && (
<>
<Spacer />
<IconButton
icon={<FaPen />}
aria-label="Edit Summary"
onClick={onEditClick}
/>
size="sm"
variant="subtle"
>
<LuPen />
</IconButton>
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}

View File

@@ -4,10 +4,9 @@ import useTranscript from "../useTranscript";
import useTopics from "../useTopics";
import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
import { TopicList } from "../topicList";
import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react";
import "../../../styles/button.css";
import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
@@ -104,7 +103,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
base: "auto minmax(0, 1fr) minmax(0, 1fr)",
md: "auto minmax(0, 1fr)",
}}
gap={2}
gap={4}
gridRowGap={2}
padding={4}
paddingBottom={0}
background="gray.bg"

View File

@@ -1,10 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../../topicList";
import { TopicList } from "../_components/TopicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
@@ -105,7 +104,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
</Box>
<Box w={{ md: "50%" }} h={{ base: "20%", md: "full" }}>
{!transcriptStarted ? (
<Box textAlign={"center"} textColor="gray">
<Box textAlign={"center"} color="gray">
<Text>
Live transcript will appear here shortly after you'll start
recording.

View File

@@ -1,179 +0,0 @@
import React, { useContext, useState, useEffect } from "react";
import SelectSearch from "react-select-search";
import { GetTranscript, GetTranscriptTopic } from "../../../api";
import "react-select-search/style.css";
import { DomainContext } from "../../../domainContext";
import useApi from "../../../lib/useApi";
type ShareModalProps = {
show: boolean;
setShow: (show: boolean) => void;
transcript: GetTranscript | null;
topics: GetTranscriptTopic[] | null;
};
interface Stream {
stream_id: number;
name: string;
}
interface Topic {
name: string;
}
interface SelectSearchOption {
name: string;
value: string;
}
const ShareModal = (props: ShareModalProps) => {
const [stream, setStream] = useState<string | undefined>(undefined);
const [topic, setTopic] = useState<string | undefined>(undefined);
const [includeTopics, setIncludeTopics] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const api = useApi();
useEffect(() => {
const fetchZulipStreams = async () => {
if (!api) return;
try {
const response = await api.v1ZulipGetStreams();
setStreams(response);
setIsLoading(false);
} catch (error) {
console.error("Error fetching Zulip streams:", error);
}
};
fetchZulipStreams();
}, [!api]);
useEffect(() => {
const fetchZulipTopics = async () => {
if (!api || !stream) return;
try {
const selectedStream = streams.find((s) => s.name === stream);
if (selectedStream) {
const response = await api.v1ZulipGetTopics({
streamId: selectedStream.stream_id,
});
setTopics(response);
}
} catch (error) {
console.error("Error fetching Zulip topics:", error);
}
};
fetchZulipTopics();
}, [stream, streams, api]);
const handleSendToZulip = async () => {
if (!api || !props.transcript) return;
if (stream && topic) {
try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcript.id,
stream,
topic,
includeTopics,
});
} catch (error) {
console.log(error);
}
}
};
if (props.show && isLoading) {
return <div>Loading...</div>;
}
const streamOptions: SelectSearchOption[] = streams.map((stream) => ({
name: stream.name,
value: stream.name,
}));
const topicOptions: SelectSearchOption[] = topics.map((topic) => ({
name: topic.name,
value: topic.name,
}));
return (
<div className="absolute">
{props.show && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<h3 className="font-bold text-xl">Send to Zulip</h3>
{/* Checkbox for 'Include Topics' */}
<div className="mt-4 text-left ml-5">
<label className="flex items-center">
<input
type="checkbox"
className="form-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
checked={includeTopics}
onChange={(e) => setIncludeTopics(e.target.checked)}
/>
<span className="ml-2">Include topics</span>
</label>
</div>
<div className="flex items-center mt-4">
<span className="mr-2">#</span>
<SelectSearch
search={true}
options={streamOptions}
value={stream}
onChange={(val) => {
setTopic(undefined); // Reset topic when stream changes
setStream(val.toString());
}}
placeholder="Pick a stream"
/>
</div>
{stream && (
<div className="flex items-center mt-4">
<span className="mr-2 invisible">#</span>
<SelectSearch
search={true}
options={topicOptions}
value={topic}
onChange={(val) => setTopic(val.toString())}
placeholder="Pick a topic"
/>
</div>
)}
<button
className={`bg-blue-400 hover:bg-blue-500 focus-visible:bg-blue-500 text-white rounded py-2 px-4 mr-3 ${
!stream || !topic ? "opacity-50 cursor-not-allowed" : ""
}`}
disabled={!stream || !topic}
onClick={() => {
handleSendToZulip();
props.setShow(false);
}}
>
Send to Zulip
</button>
<button
className="bg-red-500 hover:bg-red-700 focus-visible:bg-red-700 text-white rounded py-2 px-4 mt-4"
onClick={() => props.setShow(false)}
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ShareModal;

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import "../../../../styles/button.css";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
@@ -62,18 +61,14 @@ const TranscriptUpload = (details: TranscriptUpload) => {
<>
<VStack
align={"left"}
w="full"
h="full"
mb={4}
background="gray.bg"
border={"2px solid"}
borderColor={"gray.bg"}
borderRadius={8}
p="4"
pt={4}
mx="auto"
w={{ base: "full", md: "container.xl" }}
>
<Heading size={"lg"}>Upload meeting</Heading>
<Center h={"full"} w="full">
<VStack spacing={10}>
<VStack gap={10} bg="gray.100" p={10} borderRadius="md" maxW="500px">
{status && status == "idle" && (
<>
<Text>
@@ -94,7 +89,6 @@ const TranscriptUpload = (details: TranscriptUpload) => {
processed.
</Text>
<Button
colorScheme="blue"
onClick={() => {
router.push("/browse");
}}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import useApi from "../../lib/useApi";
import { Button, CircularProgress } from "@chakra-ui/react";
import { Button, Spinner } from "@chakra-ui/react";
type FileUploadButton = {
transcriptId: string;
@@ -63,16 +63,11 @@ export default function FileUploadButton(props: FileUploadButton) {
return (
<>
<Button
onClick={triggerFileUpload}
colorScheme="blue"
mr={2}
isDisabled={progress > 0}
>
<Button onClick={triggerFileUpload} mr={2} disabled={progress > 0}>
{progress > 0 && progress < 100 ? (
<>
Uploading...&nbsp;
<CircularProgress size="20px" value={progress} />
<Spinner size="sm" />
</>
) : (
<>Select File</>

View File

@@ -2,7 +2,6 @@
import React, { useEffect, useState } from "react";
import useAudioDevice from "../useAudioDevice";
import "react-select-search/style.css";
import "../../../styles/button.css";
import "../../../styles/form.scss";
import About from "../../../(aboutAndPrivacy)/about";
import Privacy from "../../../(aboutAndPrivacy)/privacy";
@@ -30,15 +29,6 @@ import {
IconButton,
Spacer,
Menu,
MenuButton,
MenuItem,
MenuList,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Tooltip,
Input,
} from "@chakra-ui/react";
@@ -107,18 +97,18 @@ const TranscriptCreate = () => {
>
<Flex
flexDir={{ base: "column", md: "row" }}
justify="space-between"
align="center"
justifyContent="space-between"
alignItems="center"
gap={8}
>
<Flex
flexDir="column"
h="full"
justify="evenly"
justifyContent="evenly"
flexBasis="1"
flexGrow={1}
>
<Heading size="lg" textAlign={{ base: "center", md: "left" }}>
<Heading size="2xl" textAlign={{ base: "center", md: "left" }}>
Welcome to Reflector
</Heading>
<Text mt={6}>
@@ -143,9 +133,7 @@ const TranscriptCreate = () => {
{isLoading ? (
<Spinner />
) : requireLogin && !isAuthenticated ? (
<Button onClick={() => signIn("authentik")} colorScheme="blue">
Log in
</Button>
<Button onClick={() => signIn("authentik")}>Log in</Button>
) : (
<Flex
rounded="xl"
@@ -156,7 +144,7 @@ const TranscriptCreate = () => {
flexDir="column"
my={4}
>
<Heading size="md" mb={4}>
<Heading size="xl" mb={4}>
Try Reflector
</Heading>
<Box mb={4}>
@@ -191,7 +179,7 @@ const TranscriptCreate = () => {
</Text>
) : (
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={requestPermission}
disabled={permissionDenied}
>
@@ -202,20 +190,20 @@ const TranscriptCreate = () => {
<Text className="">Checking permissions...</Text>
)}
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={send}
isDisabled={!permissionOk || loadingRecord || loadingUpload}
disabled={!permissionOk || loadingRecord || loadingUpload}
mt={2}
>
{loadingRecord ? "Loading..." : "Record Meeting"}
</Button>
<Text align="center" m="2">
<Text textAlign="center" m="2">
OR
</Text>
<Button
colorScheme="whiteAlpha"
colorPalette="whiteAlpha"
onClick={uploadFile}
isDisabled={loadingRecord || loadingUpload}
disabled={loadingRecord || loadingUpload}
>
{loadingUpload ? "Loading..." : "Upload File"}
</Button>

View File

@@ -8,8 +8,7 @@ import { Topic } from "./webSocketTypes";
import { AudioWaveform } from "../../api";
import { waveSurferStyles } from "../../styles/recorder";
import { Box, Flex, IconButton } from "@chakra-ui/react";
import PlayIcon from "../../styles/icons/play";
import PauseIcon from "../../styles/icons/pause";
import { LuPause, LuPlay } from "react-icons/lu";
type PlayerProps = {
topics: Topic[];
@@ -167,13 +166,15 @@ export default function Player(props: PlayerProps) {
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isPlaying ? "Pause" : "Play"}
icon={isPlaying ? <PauseIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
mr={2}
id="play-btn"
onClick={handlePlayClick}
/>
size="sm"
>
{isPlaying ? <LuPause /> : <LuPlay />}
</IconButton>
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>

View File

@@ -9,20 +9,8 @@ import { useError } from "../../(errors)/errorContext";
import FileUploadButton from "./fileUploadButton";
import useWebRTC from "./useWebRTC";
import useAudioDevice from "./useAudioDevice";
import {
Box,
Flex,
IconButton,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
} from "@chakra-ui/react";
import StopRecordIcon from "../../styles/icons/stopRecord";
import PlayIcon from "../../styles/icons/play";
import { LuScreenShare } from "react-icons/lu";
import { FaMicrophone } from "react-icons/fa";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuStopCircle } from "react-icons/lu";
type RecorderProps = {
transcriptId: string;
@@ -139,7 +127,7 @@ export default function Recorder(props: RecorderProps) {
} else {
clearInterval(timeInterval as number);
setCurrentTime((prev) => {
setDuration(prev);
setDuration(prev / 1000);
return 0;
});
}
@@ -260,48 +248,56 @@ export default function Recorder(props: RecorderProps) {
<Flex className="flex items-center w-full relative">
<IconButton
aria-label={isRecording ? "Stop" : "Record"}
icon={isRecording ? <StopRecordIcon /> : <PlayIcon />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
mr={2}
onClick={handleRecClick}
/>
>
{isRecording ? <LuStopCircle /> : <LuPlay />}
</IconButton>
{!isRecording && (window as any).chrome && (
<IconButton
aria-label={"Record Tab"}
icon={<LuScreenShare />}
variant={"ghost"}
colorScheme={"blue"}
colorPalette={"blue"}
disabled={isRecording}
mr={2}
onClick={handleRecordTabClick}
/>
size="sm"
>
<LuScreenShare />
</IconButton>
)}
{audioDevices && audioDevices?.length > 0 && deviceId && !isRecording && (
<Menu>
<MenuButton
as={IconButton}
aria-label={"Switch microphone"}
icon={<FaMicrophone />}
variant={"ghost"}
disabled={isRecording}
colorScheme={"blue"}
mr={2}
/>
<MenuList>
<MenuOptionGroup defaultValue={audioDevices[0].value} type="radio">
{audioDevices.map((device) => (
<MenuItemOption
key={device.value}
value={device.value}
onClick={() => setDeviceId(device.value)}
>
{device.label}
</MenuItemOption>
))}
</MenuOptionGroup>
</MenuList>
</Menu>
<Menu.Root>
<Menu.Trigger asChild>
<IconButton
aria-label={"Switch microphone"}
variant={"ghost"}
disabled={isRecording}
colorPalette={"blue"}
mr={2}
size="sm"
>
<LuMic />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.RadioItemGroup
value={deviceId}
onValueChange={(e) => setDeviceId(e.value)}
>
{audioDevices.map((device) => (
<Menu.RadioItem key={device.value} value={device.value}>
<Menu.ItemIndicator />
{device.label}
</Menu.RadioItem>
))}
</Menu.RadioItemGroup>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
)}
<Box position="relative" flex={1}>
<Box ref={waveformRef} height={14}></Box>

View File

@@ -7,18 +7,17 @@ import {
Box,
Flex,
IconButton,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
Dialog,
Portal,
CloseButton,
Select,
createListCollection,
} from "@chakra-ui/react";
import { FaShare } from "react-icons/fa";
import { LuShare2 } from "react-icons/lu";
import useApi from "../../lib/useApi";
import useSessionUser from "../../lib/useSessionUser";
import { CustomSession } from "../../lib/types";
import { Select } from "chakra-react-select";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
@@ -31,31 +30,41 @@ type ShareAndPrivacyProps = {
type ShareOption = { value: ShareMode; label: string };
const shareOptions = [
const shareOptionsData = [
{ label: "Private", value: toShareMode("private") },
{ label: "Secure", value: toShareMode("semi-private") },
{ label: "Public", value: toShareMode("public") },
];
const shareOptions = createListCollection({
items: shareOptionsData,
});
export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [showModal, setShowModal] = useState(false);
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>(
shareOptions.find(
shareOptionsData.find(
(option) => option.value === props.transcriptResponse.share_mode,
) || shareOptions[0],
) || shareOptionsData[0],
);
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const api = useApi();
const updateShareMode = async (selectedShareMode: any) => {
const updateShareMode = async (selectedValue: string) => {
if (!api)
throw new Error("ShareLink's API should always be ready at this point");
const selectedOption = shareOptionsData.find(
(option) => option.value === selectedValue,
);
if (!selectedOption) return;
setShareLoading(true);
const requestBody: UpdateTranscript = {
share_mode: toShareMode(selectedShareMode.value),
share_mode: selectedValue as "public" | "semi-private" | "private",
};
const updatedTranscript = await api.v1TranscriptUpdate({
@@ -63,9 +72,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
requestBody,
});
setShareMode(
shareOptions.find(
shareOptionsData.find(
(option) => option.value === updatedTranscript.share_mode,
) || shareOptions[0],
) || shareOptionsData[0],
);
setShareLoading(false);
};
@@ -79,72 +88,102 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
return (
<>
<IconButton
icon={<FaShare />}
onClick={() => setShowModal(true)}
aria-label="Share"
/>
<Modal
isOpen={!!showModal}
onClose={() => setShowModal(false)}
size={"xl"}
size="sm"
variant="subtle"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Share</ModalHeader>
<ModalBody>
{requireLogin && (
<Box mb={4}>
<Text size="sm" mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text size="sm" mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
<LuShare2 />
</IconButton>
<Dialog.Root
open={showModal}
onOpenChange={(e) => setShowModal(e.open)}
size="lg"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Share</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{requireLogin && (
<Box mb={4}>
<Text mb="2" fontWeight={"bold"}>
Share mode
</Text>
<Text mb="2">
{shareMode.value === "private" &&
"This transcript is private and can only be accessed by you."}
{shareMode.value === "semi-private" &&
"This transcript is secure. Only authenticated users can access it."}
{shareMode.value === "public" &&
"This transcript is public. Everyone can access it."}
</Text>
{isOwner && api && (
<Select
options={
[
{ value: "private", label: "Private" },
{ label: "Secure", value: "semi-private" },
{ label: "Public", value: "public" },
] as any
}
value={shareMode}
onChange={updateShareMode}
isLoading={shareLoading}
{isOwner && api && (
<Select.Root
key={shareMode.value}
value={[shareMode.value]}
onValueChange={(e) => updateShareMode(e.value[0])}
disabled={shareLoading}
collection={shareOptions}
lazyMount={true}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText>{shareMode.label}</Select.ValueText>
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{shareOptions.items.map((option) => (
<Select.Item
key={option.value}
item={option}
label={option.label}
>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
)}
</Box>
)}
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
</Box>
)}
<Text size="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</Flex>
</Flex>
<ShareLink transcriptId={props.transcriptResponse.id} />
</ModalBody>
</ModalContent>
</Modal>
<ShareLink transcriptId={props.transcriptResponse.id} />
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -46,15 +46,10 @@ export default function ShareCopy({
return (
<Box {...boxProps}>
<Button
onClick={onCopyTranscriptClick}
colorScheme="blue"
size={"sm"}
mr={2}
>
<Button onClick={onCopyTranscriptClick} mr={2} variant="subtle">
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</Button>
<Button onClick={onCopySummaryClick} colorScheme="blue" size={"sm"}>
<Button onClick={onCopySummaryClick} variant="subtle">
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</Button>
</Box>

View File

@@ -63,7 +63,7 @@ const ShareLink = (props: ShareLinkProps) => {
onChange={() => {}}
mx="2"
/>
<Button onClick={handleCopyClick} colorScheme="blue">
<Button onClick={handleCopyClick}>
{isCopied ? "Copied!" : "Copy"}
</Button>
</Flex>

View File

@@ -1,8 +1,23 @@
import { useState } from "react";
import { useState, useEffect, useMemo } from "react";
import { featureEnabled } from "../../domainContext";
import ShareModal from "./[transcriptId]/shareModal";
import { GetTranscript, GetTranscriptTopic } from "../../api";
import { BoxProps, Button } from "@chakra-ui/react";
import {
BoxProps,
Button,
Dialog,
CloseButton,
Text,
Box,
Flex,
Checkbox,
Combobox,
Spinner,
Portal,
useFilter,
useListCollection,
} from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb";
import useApi from "../../lib/useApi";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
@@ -10,27 +25,251 @@ type ShareZulipProps = {
disabled: boolean;
};
interface Stream {
stream_id: number;
name: string;
}
interface Topic {
name: string;
}
export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const [showModal, setShowModal] = useState(false);
const [stream, setStream] = useState<string | undefined>(undefined);
const [topic, setTopic] = useState<string | undefined>(undefined);
const [includeTopics, setIncludeTopics] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const api = useApi();
const { contains } = useFilter({ sensitivity: "base" });
const {
collection: streamItemsCollection,
filter: streamItemsFilter,
set: streamItemsSet,
} = useListCollection({
items: [],
filter: contains,
});
const {
collection: topicItemsCollection,
filter: topicItemsFilter,
set: topicItemsSet,
} = useListCollection({
items: [],
filter: contains,
});
useEffect(() => {
const fetchZulipStreams = async () => {
if (!api) return;
try {
const response = await api.v1ZulipGetStreams();
setStreams(response);
streamItemsSet(
response.map((stream) => ({
label: stream.name,
value: stream.name,
})),
);
setIsLoading(false);
} catch (error) {
console.error("Error fetching Zulip streams:", error);
}
};
fetchZulipStreams();
}, [!api]);
useEffect(() => {
const fetchZulipTopics = async () => {
if (!api || !stream) return;
try {
const selectedStream = streams.find((s) => s.name === stream);
if (selectedStream) {
const response = await api.v1ZulipGetTopics({
streamId: selectedStream.stream_id,
});
setTopics(response);
topicItemsSet(
response.map((topic) => ({
label: topic.name,
value: topic.name,
})),
);
} else {
topicItemsSet([]);
}
} catch (error) {
console.error("Error fetching Zulip topics:", error);
}
};
fetchZulipTopics();
}, [stream, streams, api]);
const handleSendToZulip = async () => {
if (!api || !props.transcriptResponse) return;
if (stream && topic) {
try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcriptResponse.id,
stream,
topic,
includeTopics,
});
setShowModal(false);
} catch (error) {
console.log(error);
}
}
};
if (!featureEnabled("sendToZulip")) return null;
return (
<>
<Button
colorScheme="blue"
size={"sm"}
isDisabled={props.disabled}
onClick={() => setShowModal(true)}
>
Send to Zulip
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
<TbBrandZulip /> Send to Zulip
</Button>
<ShareModal
transcript={props.transcriptResponse}
topics={props.topicsResponse}
show={showModal}
setShow={(v) => setShowModal(v)}
/>
<Dialog.Root
open={showModal}
onOpenChange={(e) => setShowModal(e.open)}
size="md"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send to Zulip</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{isLoading ? (
<Flex justify="center" py={8}>
<Spinner />
</Flex>
) : (
<>
<Box mb={4}>
<Checkbox.Root
checked={includeTopics}
onCheckedChange={(e) => setIncludeTopics(!!e.checked)}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Include topics</Checkbox.Label>
</Checkbox.Root>
</Box>
<Box mb={4}>
<Flex align="center" gap={2}>
<Text>#</Text>
<Combobox.Root
collection={streamItemsCollection}
value={stream ? [stream] : []}
onValueChange={(e) => {
setTopic(undefined);
setStream(e.value[0]);
}}
onInputValueChange={(e) =>
streamItemsFilter(e.inputValue)
}
openOnClick={true}
positioning={{
strategy: "fixed",
hideWhenDetached: true,
}}
>
<Combobox.Control>
<Combobox.Input placeholder="Pick a stream" />
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No streams found</Combobox.Empty>
{streamItemsCollection.items.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
</Combobox.Item>
))}
</Combobox.Content>
</Combobox.Positioner>
</Combobox.Root>
</Flex>
</Box>
{stream && (
<Box mb={4}>
<Flex align="center" gap={2}>
<Text visibility="hidden">#</Text>
<Combobox.Root
collection={topicItemsCollection}
value={topic ? [topic] : []}
onValueChange={(e) => setTopic(e.value[0])}
onInputValueChange={(e) =>
topicItemsFilter(e.inputValue)
}
openOnClick
selectionBehavior="replace"
skipAnimationOnMount={true}
closeOnSelect={true}
positioning={{
strategy: "fixed",
hideWhenDetached: true,
}}
>
<Combobox.Control>
<Combobox.Input placeholder="Pick a topic" />
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Combobox.Positioner>
<Combobox.Content>
<Combobox.Empty>No topics found</Combobox.Empty>
{topicItemsCollection.items.map((item) => (
<Combobox.Item key={item.value} item={item}>
{item.label}
<Combobox.ItemIndicator />
</Combobox.Item>
))}
</Combobox.Content>
</Combobox.Positioner>
</Combobox.Root>
</Flex>
</Box>
)}
</>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setShowModal(false)}>
Close
</Button>
<Button disabled={!stream || !topic} onClick={handleSendToZulip}>
Send to Zulip
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { UpdateTranscript } from "../../api";
import useApi from "../../lib/useApi";
import { Heading, IconButton, Input } from "@chakra-ui/react";
import { FaPen } from "react-icons/fa";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
type TranscriptTitle = {
title: string;
@@ -87,23 +87,26 @@ const TranscriptTitle = (props: TranscriptTitle) => {
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 w-full border-none bg-transparent overflow-hidden h-[fit-content]"
/>
) : (
<>
<Flex alignItems="center">
<Heading
// className="text-2xl lg:text-4xl font-extrabold text-center mb-4 cursor-pointer"
onClick={handleTitleClick}
cursor={"pointer"}
size={"lg"}
noOfLines={1}
lineClamp={1}
pr={2}
>
{displayedTitle}
</Heading>
<Spacer />
<IconButton
icon={<FaPen />}
aria-label="Edit Transcript Title"
onClick={handleTitleClick}
fontSize={"15px"}
/>
</>
size="sm"
variant="subtle"
>
<LuPen />
</IconButton>
</Flex>
)}
</>
);

View File

@@ -39,6 +39,7 @@ const useTranscriptList = (
sourceKind,
roomId,
searchTerm,
size: 10,
})
.then((response) => {
setResponse(response);
@@ -50,7 +51,7 @@ const useTranscriptList = (
setError(err);
setErrorState(err);
});
}, [!api, page, refetchCount, roomId, searchTerm]);
}, [api, page, refetchCount, roomId, searchTerm, sourceKind]);
return { response, loading, error, refetch };
};

View File

@@ -2,6 +2,6 @@ import { Center, Spinner } from "@chakra-ui/react";
export default () => (
<Center h={14}>
<Spinner speed="1s"></Spinner>
<Spinner />
</Center>
);

View File

@@ -7,7 +7,7 @@ export default function UserInfo() {
const { isLoading, isAuthenticated } = useSessionStatus();
return isLoading ? (
<Spinner size="xs" thickness="1px" className="mx-3" />
<Spinner size="xs" className="mx-3" />
) : !isAuthenticated ? (
<Link
href="/"

View File

@@ -15,9 +15,9 @@ import {
VStack,
HStack,
Spinner,
useToast,
Icon,
} from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
import useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation";
import { notFound } from "next/navigation";
@@ -80,7 +80,6 @@ const useConsentDialog = (
// toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false);
const api = useApi();
const toast = useToast();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
@@ -109,24 +108,23 @@ const useConsentDialog = (
setModalOpen(true);
const TOAST_NEVER_DISMISS_VALUE = null;
const toastId = toast({
position: "top",
duration: TOAST_NEVER_DISMISS_VALUE,
render: ({ onClose }) => {
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => {
const AcceptButton = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
return (
<Button
ref={buttonRef}
colorScheme="blue"
colorPalette="blue"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
onClose();
dismiss();
}}
>
Yes, store the audio
@@ -143,21 +141,21 @@ const useConsentDialog = (
maxW="md"
mx="auto"
>
<VStack spacing={4} align="center">
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack spacing={4} justify="center">
<HStack gap={4} justifyContent="center">
<AcceptButton />
<Button
colorScheme="gray"
colorPalette="gray"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
onClose();
dismiss();
}}
>
No, delete after transcription
@@ -167,27 +165,34 @@ const useConsentDialog = (
</Box>
);
},
onCloseComplete: () => {
setModalOpen(false);
},
});
// Set modal state when toast is dismissed
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, 100);
});
// Handle escape key to close the toast
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toast.close(toastId);
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toast.close(toastId);
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, toast, handleConsent, wherebyRef, modalOpen]);
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
return { showConsentModal, consentState, hasConsent, consentLoading };
};
@@ -212,7 +217,7 @@ function ConsentDialogButton({
top="56px"
left="8px"
zIndex={1000}
colorScheme="blue"
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
@@ -294,13 +299,7 @@ export default function Room(details: RoomDetails) {
bg="gray.50"
p={4}
>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
<Spinner color="blue.500" size="xl" />
</Box>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
// Simple toaster implementation for migration
// This is a temporary solution until we properly configure Chakra UI v3 toasts
interface ToastOptions {
placement?: string;
duration?: number | null;
render: (props: { dismiss: () => void }) => React.ReactNode;
}
class ToasterClass {
private toasts: Map<string, any> = new Map();
private nextId = 1;
create(options: ToastOptions): Promise<string> {
const id = String(this.nextId++);
this.toasts.set(id, options);
// For now, we'll render toasts using a portal or modal
// This is a simplified implementation
if (typeof window !== "undefined") {
console.log("Toast created:", id, options);
// Auto-dismiss after duration if specified
if (options.duration !== null) {
setTimeout(() => {
this.dismiss(id);
}, options.duration || 5000);
}
}
return Promise.resolve(id);
}
dismiss(id: string) {
this.toasts.delete(id);
console.log("Toast dismissed:", id);
}
isActive(id: string): boolean {
return this.toasts.has(id);
}
}
export const toaster = new ToasterClass();
// Empty Toaster component for now
export const Toaster = () => null;

View File

@@ -1,5 +1,6 @@
import "./styles/globals.scss";
import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google";
import SessionProvider from "./lib/SessionProvider";
import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage";
@@ -9,6 +10,12 @@ import { getConfig } from "./lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
const poppins = Poppins({
subsets: ["latin"],
weight: ["200", "400", "600"],
display: "swap",
});
export const viewport: Viewport = {
themeColor: "black",
width: "device-width",
@@ -65,7 +72,7 @@ export default async function RootLayout({
const config = await getConfig();
return (
<html lang="en">
<html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
<SessionProvider>
<DomainContextProvider config={config}>

View File

@@ -1,7 +1,8 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, useToast, Text } from "@chakra-ui/react";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
interface WherebyEmbedProps {
roomUrl: string;
@@ -16,34 +17,31 @@ export default function WherebyWebinarEmbed({
const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
const toast = useToast();
useEffect(() => {
if (roomUrl && !localStorage.getItem("recording-notice-dismissed")) {
const toastId = toast({
position: "top",
const toastIdPromise = toaster.create({
placement: "top",
duration: null,
render: ({ onClose }) => (
render: ({ dismiss }) => (
<Box p={4} bg="white" borderRadius="md" boxShadow="md">
<HStack justify="space-between" align="center">
<HStack justifyContent="space-between" alignItems="center">
<Text>
This webinar is being recorded. By continuing, you agree to our{" "}
<Button
as="a"
<Link
href="https://monadical.com/privacy"
variant="link"
color="blue.600"
textDecoration="underline"
target="_blank"
>
Privacy Policy
</Button>
</Link>
</Text>
<Button
size="sm"
variant="ghost"
onClick={() => {
localStorage.setItem("recording-notice-dismissed", "true");
onClose();
dismiss();
}}
>
@@ -54,10 +52,10 @@ export default function WherebyWebinarEmbed({
});
return () => {
toast.close(toastId);
toastIdPromise.then((id) => toaster.dismiss(id));
};
}
}, [roomUrl, toast]);
}, [roomUrl]);
const handleLeave = () => {
if (onLeave) {

View File

@@ -26,13 +26,13 @@ export const ExpandableText = forwardRef<HTMLDivElement, Props>(
return (
<Box ref={ref} {...rest}>
<Box ref={inputRef} noOfLines={expandedCount}>
<Box ref={inputRef} lineClamp={expandedCount}>
{children}
</Box>
<Button
display={isTextClamped ? "block" : "none"}
size="sm"
variant="link"
variant="ghost"
onClick={handleToggle}
mt={2}
>

View File

@@ -1,13 +1,13 @@
"use client";
import { ChakraProvider } from "@chakra-ui/react";
import theme from "./styles/theme";
import system from "./styles/theme";
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ChakraProvider theme={theme}>
<ChakraProvider value={system}>
<WherebyProvider>{children}</WherebyProvider>
</ChakraProvider>
);

View File

@@ -1,120 +0,0 @@
/* Define basic button styles */
input[type="button"],
button {
/* Reset default button styles */
border: none;
background-color: transparent;
font-family: inherit;
padding: 0;
/* Visual */
border-radius: 8px;
/* Size */
padding: 0.4em 1em;
min-height: 44px;
/* Text */
text-align: center;
line-height: 1.1;
/* Display */
display: inline-flex;
align-items: center;
justify-content: center;
/* Animation */
transition: 220ms all ease-in-out;
}
button:focus-visible {
outline-style: none;
}
@media (max-width: 768px) {
input[type="button"],
button {
padding: 0.25em 0.75em;
min-height: 30px;
}
}
/* Button modifiers */
input[type="button"].small,
button.small {
font-size: 1.15rem;
}
input[type="button"].block,
button.block {
width: 100%;
}
/* Disabled styles */
/* input[type="button"][disabled], */
/* button[disabled] { */
/* border-color: #ccc; */
/* background: #b8b8b8 !important; */
/* cursor: not-allowed; */
/* } */
/**/
/* input[type="button"][disabled]:hover, */
/* button[disabled]:hover { */
/* background: #b8b8b8 !important; */
/* cursor: not-allowed !important; */
/* } */
/* Red button states */
input[type="button"][data-color="red"],
button[data-color="red"],
input[type="button"][data-color="red"]:hover,
button[data-color="red"]:hover,
input[type="button"][data-color="red"]:active,
button[data-color="red"]:active {
background-color: #cc3347;
}
/* Green button states */
input[type="button"][data-color="green"],
button[data-color="green"],
input[type="button"][data-color="green"]:hover,
button[data-color="green"]:hover,
input[type="button"][data-color="green"]:active,
button[data-color="green"]:active {
background-color: #33cc47;
}
/* Blue button states */
input[type="button"][data-color="blue"],
button[data-color="blue"],
input[type="button"][data-color="blue"]:hover,
button[data-color="blue"]:hover,
input[type="button"][data-color="blue"]:active,
button[data-color="blue"]:active {
background-color: #3347cc;
}
/* Orange button states */
input[type="button"][data-color="orange"],
button[data-color="orange"],
input[type="button"][data-color="orange"]:hover,
button[data-color="orange"]:hover,
input[type="button"][data-color="orange"]:active,
button[data-color="orange"]:active {
background-color: #ff7f00;
}
/* Purple button states */
input[type="button"][data-color="purple"],
button[data-color="purple"],
input[type="button"][data-color="purple"]:hover,
button[data-color="purple"]:hover,
input[type="button"][data-color="purple"]:active,
button[data-color="purple"]:active {
background-color: #800080;
}
/* Yellow button states */
input[type="button"][data-color="yellow"],
button[data-color="yellow"],
input[type="button"][data-color="yellow"]:hover,
button[data-color="yellow"]:hover,
input[type="button"][data-color="yellow"]:active,
button[data-color="yellow"]:active {
background-color: #ffff00;
}

View File

@@ -2,13 +2,15 @@ import { Icon } from "@chakra-ui/react";
export default function PauseIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M11.514 5.5C11.514 4.11929 10.3947 3 9.01404 3C7.63333 3 6.51404 4.11929 6.51404 5.5V24.5C6.51404 25.8807 7.63333 27 9.01404 27C10.3947 27 11.514 25.8807 11.514 24.5L11.514 5.5ZM23.486 5.5C23.486 4.11929 22.3667 3 20.986 3C19.6053 3 18.486 4.11929 18.486 5.5L18.486 24.5C18.486 25.8807 19.6053 27 20.986 27C22.3667 27 23.486 25.8807 23.486 24.5V5.5Z"
/>
<Icon {...props}>
<svg viewBox="0 0 30 30">
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M11.514 5.5C11.514 4.11929 10.3947 3 9.01404 3C7.63333 3 6.51404 4.11929 6.51404 5.5V24.5C6.51404 25.8807 7.63333 27 9.01404 27C10.3947 27 11.514 25.8807 11.514 24.5L11.514 5.5ZM23.486 5.5C23.486 4.11929 22.3667 3 20.986 3C19.6053 3 18.486 4.11929 18.486 5.5L18.486 24.5C18.486 25.8807 19.6053 27 20.986 27C22.3667 27 23.486 25.8807 23.486 24.5V5.5Z"
/>
</svg>
</Icon>
);
}

View File

@@ -2,11 +2,13 @@ import { Icon } from "@chakra-ui/react";
export default function PlayIcon(props) {
return (
<Icon viewBox="0 0 30 30" {...props}>
<path
fill="currentColor"
d="M27 13.2679C28.3333 14.0377 28.3333 15.9622 27 16.732L10.5 26.2583C9.16666 27.0281 7.5 26.0659 7.5 24.5263L7.5 5.47372C7.5 3.93412 9.16667 2.97187 10.5 3.74167L27 13.2679Z"
/>
<Icon {...props}>
<svg viewBox="0 0 30 30">
<path
fill="currentColor"
d="M27 13.2679C28.3333 14.0377 28.3333 15.9622 27 16.732L10.5 26.2583C9.16666 27.0281 7.5 26.0659 7.5 24.5263L7.5 5.47372C7.5 3.93412 9.16667 2.97187 10.5 3.74167L27 13.2679Z"
/>
</svg>
</Icon>
);
}

View File

@@ -2,8 +2,10 @@ import { Icon } from "@chakra-ui/react";
export default function StopRecordIcon(props) {
return (
<Icon viewBox="0 0 20 20" {...props}>
<rect width="20" height="20" rx="1" fill="currentColor" />
<Icon {...props}>
<svg viewBox="0 0 20 20">
<rect width="20" height="20" rx="1" fill="currentColor" />
</svg>
</Icon>
);
}

View File

@@ -10,7 +10,7 @@
}
.markdown h1 {
font-size: 1.2em;
font-size: 1.1em;
font-weight: bold;
/* text-decoration: underline;
text-underline-offset: 0.2em; */

View File

@@ -1,10 +1,9 @@
import { theme } from "@chakra-ui/react";
// Hardcoded colors for now - can be replaced with token system in Chakra v3
export const waveSurferStyles = {
playerSettings: {
waveColor: theme.colors.blue[500],
progressColor: theme.colors.blue[700],
cursorColor: theme.colors.red[500],
waveColor: "#3182ce",
progressColor: "#2c5282",
cursorColor: "#e53e3e",
hideScrollbar: true,
autoScroll: false,
autoCenter: false,
@@ -31,5 +30,5 @@ export const waveSurferStyles = {
transition: width 100ms linear;
z-index: 0;
`,
markerHover: { backgroundColor: theme.colors.gray[200] },
markerHover: { backgroundColor: "#e2e8f0" },
};

View File

@@ -1,83 +1,182 @@
import { extendTheme } from "@chakra-ui/react";
import { Poppins } from "next/font/google";
import { accordionAnatomy } from "@chakra-ui/anatomy";
import { createMultiStyleConfigHelpers, defineStyle } from "@chakra-ui/react";
import {
createSystem,
defaultConfig,
defineConfig,
defineRecipe,
defineSlotRecipe,
defaultSystem,
} from "@chakra-ui/react";
const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(accordionAnatomy.keys);
const poppins = Poppins({
subsets: ["latin"],
weight: ["200", "400", "600"],
display: "swap",
});
const custom = definePartsStyle({
container: {
border: "0",
borderRadius: "8px",
backgroundColor: "white",
mb: 2,
mr: 2,
},
panel: {
pl: 8,
pb: 0,
},
button: {
justifyContent: "flex-start",
pl: 2,
const accordionSlotRecipe = defineSlotRecipe({
slots: [
"root",
"container",
"item",
"itemTrigger",
"itemContent",
"itemIndicator",
],
base: {
item: {
bg: "white",
borderRadius: "xl",
border: "0",
mb: "2",
width: "full",
},
itemTrigger: {
p: "2",
cursor: "pointer",
_hover: {
bg: "gray.200",
},
},
},
});
const accordionTheme = defineMultiStyleConfig({
variants: { custom },
});
const linkTheme = defineStyle({
baseStyle: {
const linkRecipe = defineRecipe({
className: "link",
base: {
textDecoration: "none",
_hover: {
color: "blue.500",
textDecoration: "none",
},
_focus: {
outline: "none",
boxShadow: "none",
},
_focusVisible: {
outline: "none",
boxShadow: "none",
},
},
variants: {
variant: {
plain: {
_hover: {
textDecoration: "none",
},
},
},
},
});
const buttonRecipe = defineRecipe({
base: {
fontWeight: "600",
_hover: {
bg: "gray.200",
},
_focus: {
outline: "none",
boxShadow: "none",
},
_focusVisible: {
outline: "none",
boxShadow: "none",
},
},
variants: {
variant: {
solid: {
colorPalette: "blue",
},
outline: {
_hover: {
bg: "gray.200",
},
},
},
},
compoundVariants: [
{
colorPalette: "whiteAlpha",
css: {
bg: "whiteAlpha.500",
color: "white",
_hover: {
bg: "whiteAlpha.600",
opacity: 1,
},
},
},
],
});
export const colors = {
blue: {
primary: "#3158E2",
500: "#3158E2",
light: "#B1CBFF",
200: "#B1CBFF",
dark: "#0E1B48",
900: "#0E1B48",
primary: { value: "#3158E2" },
500: { value: "#3158E2" },
light: { value: "#B1CBFF" },
200: { value: "#B1CBFF" },
dark: { value: "#0E1B48" },
900: { value: "#0E1B48" },
},
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
primary: { value: "#DF7070" },
500: { value: "#DF7070" },
light: { value: "#FBD5D5" },
200: { value: "#FBD5D5" },
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
solid: { value: "#F4F4F4" },
bg: { value: "#F4F4F4" },
100: { value: "#F4F4F4" },
light: { value: "#D5D5D5" },
200: { value: "#D5D5D5" },
primary: { value: "#838383" },
500: { value: "#838383" },
800: { value: "#1A202C" },
},
light: "#FFFFFF",
dark: "#0C0D0E",
whiteAlpha: {
50: { value: "rgba(255, 255, 255, 0.04)" },
100: { value: "rgba(255, 255, 255, 0.06)" },
200: { value: "rgba(255, 255, 255, 0.08)" },
300: { value: "rgba(255, 255, 255, 0.16)" },
400: { value: "rgba(255, 255, 255, 0.24)" },
500: { value: "rgba(255, 255, 255, 0.36)" },
600: { value: "rgba(255, 255, 255, 0.48)" },
700: { value: "rgba(255, 255, 255, 0.64)" },
800: { value: "rgba(255, 255, 255, 0.80)" },
900: { value: "rgba(255, 255, 255, 0.92)" },
},
light: { value: "#FFFFFF" },
dark: { value: "#0C0D0E" },
};
const theme = extendTheme({
colors,
components: {
Accordion: accordionTheme,
Link: linkTheme,
},
fonts: {
body: poppins.style.fontFamily,
heading: poppins.style.fontFamily,
const config = defineConfig({
theme: {
tokens: {
colors,
fonts: {
heading: { value: "Poppins, sans-serif" },
body: { value: "Poppins, sans-serif" },
},
},
semanticTokens: {
colors: {
whiteAlpha: {
solid: { value: "{colors.whiteAlpha.500}" },
contrast: { value: "{colors.white}" },
fg: { value: "{colors.white}" },
muted: { value: "{colors.whiteAlpha.100}" },
subtle: { value: "{colors.whiteAlpha.50}" },
emphasized: { value: "{colors.whiteAlpha.600}" },
focusRing: { value: "{colors.whiteAlpha.500}" },
},
},
},
slotRecipes: {
accordion: accordionSlotRecipe,
},
recipes: {
link: linkRecipe,
button: buttonRecipe,
},
},
});
export default theme;
export const system = createSystem(defaultConfig, config);
export default system;

View File

@@ -9,7 +9,7 @@ const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), {
ssr: false,
});
import { FormEvent } from "react";
import { Input, FormControl } from "@chakra-ui/react";
import { Input, Field } from "@chakra-ui/react";
import { VStack } from "@chakra-ui/react";
import { Alert } from "@chakra-ui/react";
import { Text } from "@chakra-ui/react";
@@ -258,17 +258,18 @@ export default function WebinarPage(details: WebinarDetails) {
</h2>
{formSubmitted ? (
<Alert status="success" borderRadius="lg" mb={4}>
<Text>
<Alert.Root status="success" borderRadius="lg" mb={4}>
<Alert.Indicator />
<Alert.Title>
Thanks for signing up! The webinar recording will be ready
soon, and we'll email you as soon as it's available. Stay
tuned!
</Text>
</Alert>
</Alert.Title>
</Alert.Root>
) : (
<form onSubmit={handleSubmit}>
<VStack spacing={4} w="full">
<FormControl isRequired>
<VStack gap={4} w="full">
<Field.Root required>
<Input
type="text"
placeholder="Your Name"
@@ -279,8 +280,8 @@ export default function WebinarPage(details: WebinarDetails) {
setFormData({ ...formData, name: e.target.value })
}
/>
</FormControl>
<FormControl isRequired>
</Field.Root>
<Field.Root required>
<Input
type="email"
placeholder="Your Email"
@@ -291,7 +292,7 @@ export default function WebinarPage(details: WebinarDetails) {
setFormData({ ...formData, email: e.target.value })
}
/>
</FormControl>
</Field.Root>
<Input
type="text"
placeholder="Company Name"