mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-23 05:39:05 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
[{formatTime(topic.timestamp)}] - [
|
||||
{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>
|
||||
)}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TopicList } from "./TopicList";
|
||||
export { TopicItem } from "./TopicItem";
|
||||
export { TopicSegment } from "./TopicSegment";
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> Play
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={pause}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorScheme="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> 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> Play selection
|
||||
<Kbd color="blue.600">Space</Kbd> Play
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
</Skeleton>
|
||||
) : (
|
||||
<Button
|
||||
onClick={pause}
|
||||
ref={playButton}
|
||||
id="playButton"
|
||||
colorPalette="blue"
|
||||
w="120px"
|
||||
>
|
||||
<Kbd color="blue.600">Space</Kbd> Pause
|
||||
</Button>
|
||||
)}
|
||||
</WrapItem>
|
||||
<WrapItem visibility={selectedTime ? "visible" : "hidden"}>
|
||||
<Button
|
||||
disabled={!selectedTime}
|
||||
onClick={playSelection}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Kbd color="blue.600">,</Kbd> Play selection
|
||||
</Button>
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
) : (
|
||||
<Skeleton h="40px" w="container.md" margin="auto" {...chakraProps} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}}
|
||||
|
||||
@@ -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...
|
||||
<CircularProgress size="20px" value={progress} />
|
||||
<Spinner size="sm" />
|
||||
</>
|
||||
) : (
|
||||
<>Select 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,7 +63,7 @@ const ShareLink = (props: ShareLinkProps) => {
|
||||
onChange={() => {}}
|
||||
mx="2"
|
||||
/>
|
||||
<Button onClick={handleCopyClick} colorScheme="blue">
|
||||
<Button onClick={handleCopyClick}>
|
||||
{isCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@ import { Center, Spinner } from "@chakra-ui/react";
|
||||
|
||||
export default () => (
|
||||
<Center h={14}>
|
||||
<Spinner speed="1s"></Spinner>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user