mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
adds eslint
This commit is contained in:

committed by
Geoff Seemueller

parent
9698fc6f3b
commit
02c3253343
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "@chakra-ui/react";
|
||||
import { LucideHammer } from "lucide-react";
|
||||
import { toolbarButtonZIndex } from "./toolbar/Toolbar";
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { LucideHammer } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { toolbarButtonZIndex } from './toolbar/Toolbar';
|
||||
|
||||
export default function BuiltWithButton() {
|
||||
return (
|
||||
@@ -12,12 +13,12 @@ export default function BuiltWithButton() {
|
||||
bg="transparent"
|
||||
stroke="text.accent"
|
||||
color="text.accent"
|
||||
onClick={() => alert("Built by Geoff Seemueller")}
|
||||
onClick={() => alert('Built by Geoff Seemueller')}
|
||||
_hover={{
|
||||
bg: "transparent",
|
||||
bg: 'transparent',
|
||||
svg: {
|
||||
stroke: "accent.secondary",
|
||||
transition: "stroke 0.3s ease-in-out",
|
||||
stroke: 'accent.secondary',
|
||||
transition: 'stroke 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
zIndex={toolbarButtonZIndex}
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import { getColorThemes } from "../layout/theme/color-themes";
|
||||
import { Center, IconButton, VStack } from "@chakra-ui/react";
|
||||
import userOptionsStore from "../stores/UserOptionsStore";
|
||||
import { Circle } from "lucide-react";
|
||||
import { toolbarButtonZIndex } from "./toolbar/Toolbar";
|
||||
import React from "react";
|
||||
import { useIsMobile } from "./contexts/MobileContext";
|
||||
import { Center, IconButton, VStack } from '@chakra-ui/react';
|
||||
import { Circle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { getColorThemes } from '../layout/theme/color-themes';
|
||||
import userOptionsStore from '../stores/UserOptionsStore';
|
||||
|
||||
import { useIsMobile } from './contexts/MobileContext';
|
||||
import { toolbarButtonZIndex } from './toolbar/Toolbar';
|
||||
|
||||
export function ThemeSelectionOptions() {
|
||||
const children = [];
|
||||
@@ -24,11 +26,11 @@ export function ThemeSelectionOptions() {
|
||||
size={!isMobile ? 16 : 20}
|
||||
stroke="transparent"
|
||||
style={{
|
||||
background: `conic-gradient(${theme.colors.background.primary.startsWith("#") ? theme.colors.background.primary : theme.colors.background.secondary} 0 50%, ${theme.colors.text.secondary} 50% 100%)`,
|
||||
borderRadius: "50%",
|
||||
boxShadow: "0 0 0.5px 0.25px #fff",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.2s",
|
||||
background: `conic-gradient(${theme.colors.background.primary.startsWith('#') ? theme.colors.background.primary : theme.colors.background.secondary} 0 50%, ${theme.colors.text.secondary} 50% 100%)`,
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 0 0.5px 0.25px #fff',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -38,7 +40,7 @@ export function ThemeSelectionOptions() {
|
||||
color="transparent"
|
||||
_hover={{
|
||||
svg: {
|
||||
transition: "stroke 0.3s ease-in-out", // Smooth transition effect
|
||||
transition: 'stroke 0.3s ease-in-out', // Smooth transition effect
|
||||
},
|
||||
}}
|
||||
zIndex={toolbarButtonZIndex}
|
||||
@@ -47,7 +49,7 @@ export function ThemeSelectionOptions() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align={!isMobile ? "end" : "start"} p={1.2}>
|
||||
<VStack align={!isMobile ? 'end' : 'start'} p={1.2}>
|
||||
<Center>{children}</Center>
|
||||
</VStack>
|
||||
);
|
||||
|
@@ -1,11 +1,9 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Box, Center, VStack } from "@chakra-ui/react";
|
||||
import {
|
||||
welcome_home_text,
|
||||
welcome_home_tip,
|
||||
} from "../static-data/welcome_home_text";
|
||||
import {renderMarkdown} from "./markdown/MarkdownComponent";
|
||||
import { Box, Center, VStack } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { welcome_home_text, welcome_home_tip } from '../static-data/welcome_home_text';
|
||||
|
||||
import { renderMarkdown } from './markdown/MarkdownComponent';
|
||||
|
||||
function WelcomeHomeMessage({ visible }) {
|
||||
const containerVariants = {
|
||||
@@ -45,33 +43,19 @@ function WelcomeHomeMessage({ visible }) {
|
||||
<Center>
|
||||
<VStack spacing={8} align="center" maxW="400px">
|
||||
{/* Welcome Message */}
|
||||
<Box
|
||||
fontSize="sm"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
color="text.secondary"
|
||||
mt={4}
|
||||
>
|
||||
<Box fontSize="sm" fontStyle="italic" textAlign="center" color="text.secondary" mt={4}>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={visible ? "visible" : "hidden"}
|
||||
animate={visible ? 'visible' : 'hidden'}
|
||||
>
|
||||
<Box userSelect={"none"}>
|
||||
<motion.div variants={textVariants}>
|
||||
{renderMarkdown(welcome_home_text)}
|
||||
</motion.div>
|
||||
<Box userSelect={'none'}>
|
||||
<motion.div variants={textVariants}>{renderMarkdown(welcome_home_text)}</motion.div>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
<motion.div variants={textVariants}>
|
||||
<Box
|
||||
fontSize="sm"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
color="text.secondary"
|
||||
mt={1}
|
||||
>
|
||||
<Box fontSize="sm" fontStyle="italic" textAlign="center" color="text.secondary" mt={1}>
|
||||
{renderMarkdown(welcome_home_tip)}
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
@@ -1,37 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeSelectionOptions } from '../ThemeSelection';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import userOptionsStore from '../../stores/UserOptionsStore';
|
||||
import * as MobileContext from '../contexts/MobileContext';
|
||||
import { ThemeSelectionOptions } from '../ThemeSelection';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../layout/theme/color-themes', () => ({
|
||||
getColorThemes: () => [
|
||||
{
|
||||
name: 'light',
|
||||
colors: {
|
||||
{
|
||||
name: 'light',
|
||||
colors: {
|
||||
background: { primary: '#ffffff', secondary: '#f0f0f0' },
|
||||
text: { secondary: '#333333' }
|
||||
}
|
||||
text: { secondary: '#333333' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dark',
|
||||
colors: {
|
||||
{
|
||||
name: 'dark',
|
||||
colors: {
|
||||
background: { primary: '#121212', secondary: '#1e1e1e' },
|
||||
text: { secondary: '#e0e0e0' }
|
||||
}
|
||||
}
|
||||
]
|
||||
text: { secondary: '#e0e0e0' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/UserOptionsStore', () => ({
|
||||
default: {
|
||||
selectTheme: vi.fn()
|
||||
}
|
||||
selectTheme: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../toolbar/Toolbar', () => ({
|
||||
toolbarButtonZIndex: 100
|
||||
toolbarButtonZIndex: 100,
|
||||
}));
|
||||
|
||||
describe('ThemeSelectionOptions', () => {
|
||||
@@ -42,20 +43,20 @@ describe('ThemeSelectionOptions', () => {
|
||||
it('renders theme options for desktop view', () => {
|
||||
// Mock useIsMobile to return false (desktop view)
|
||||
vi.spyOn(MobileContext, 'useIsMobile').mockReturnValue(false);
|
||||
|
||||
|
||||
render(<ThemeSelectionOptions />);
|
||||
|
||||
|
||||
// Should render 2 theme buttons (from our mock)
|
||||
const buttons = screen.getAllByRole("button")
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders theme options for mobile view', () => {
|
||||
// Mock useIsMobile to return true (mobile view)
|
||||
vi.spyOn(MobileContext, 'useIsMobile').mockReturnValue(true);
|
||||
|
||||
|
||||
render(<ThemeSelectionOptions />);
|
||||
|
||||
|
||||
// Should still render 2 theme buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
@@ -63,16 +64,16 @@ describe('ThemeSelectionOptions', () => {
|
||||
|
||||
it('calls selectTheme when a theme button is clicked', () => {
|
||||
vi.spyOn(MobileContext, 'useIsMobile').mockReturnValue(false);
|
||||
|
||||
|
||||
render(<ThemeSelectionOptions />);
|
||||
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
fireEvent.click(buttons[0]); // Click the first theme button (light)
|
||||
|
||||
|
||||
// Verify that selectTheme was called with the correct theme name
|
||||
expect(userOptionsStore.selectTheme).toHaveBeenCalledWith('light');
|
||||
|
||||
|
||||
fireEvent.click(buttons[1]); // Click the second theme button (dark)
|
||||
expect(userOptionsStore.selectTheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,22 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import WelcomeHomeMessage from '../WelcomeHome';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { welcome_home_text, welcome_home_tip } from '../../static-data/welcome_home_text';
|
||||
import { renderMarkdown } from '../markdown/MarkdownComponent';
|
||||
import WelcomeHomeMessage from '../WelcomeHome';
|
||||
|
||||
// Mock the renderMarkdown function
|
||||
vi.mock('../markdown/MarkdownComponent', () => ({
|
||||
renderMarkdown: vi.fn((text) => `Rendered: ${text}`),
|
||||
renderMarkdown: vi.fn(text => `Rendered: ${text}`),
|
||||
}));
|
||||
|
||||
describe('WelcomeHomeMessage', () => {
|
||||
it('renders correctly when visible', () => {
|
||||
render(<WelcomeHomeMessage visible={true} />);
|
||||
|
||||
|
||||
// Check if the rendered markdown content is in the document
|
||||
expect(screen.getByText(`Rendered: ${welcome_home_text}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`Rendered: ${welcome_home_tip}`)).toBeInTheDocument();
|
||||
|
||||
|
||||
// Verify that renderMarkdown was called with the correct arguments
|
||||
expect(renderMarkdown).toHaveBeenCalledWith(welcome_home_text);
|
||||
expect(renderMarkdown).toHaveBeenCalledWith(welcome_home_tip);
|
||||
@@ -24,17 +25,17 @@ describe('WelcomeHomeMessage', () => {
|
||||
|
||||
it('applies animation variants based on visible prop', () => {
|
||||
const { rerender } = render(<WelcomeHomeMessage visible={true} />);
|
||||
|
||||
|
||||
// When visible is true, the component should have the visible animation state
|
||||
// Since we've mocked framer-motion, we can't directly test the animation state
|
||||
// But we can verify that the component renders the content
|
||||
expect(screen.getByText(`Rendered: ${welcome_home_text}`)).toBeInTheDocument();
|
||||
|
||||
|
||||
// Re-render with visible=false
|
||||
rerender(<WelcomeHomeMessage visible={false} />);
|
||||
|
||||
|
||||
// Content should still be in the document even when not visible
|
||||
// (since we've mocked the animations)
|
||||
expect(screen.getByText(`Rendered: ${welcome_home_text}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { Grid, GridItem, Image, Text } from "@chakra-ui/react";
|
||||
import { Grid, GridItem, Image, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const fontSize = "md";
|
||||
const fontSize = 'md';
|
||||
|
||||
function AboutComponent() {
|
||||
return (
|
||||
<Grid
|
||||
templateColumns="1fr"
|
||||
gap={4}
|
||||
maxW={["100%", "100%", "100%"]}
|
||||
maxW={['100%', '100%', '100%']}
|
||||
mx="auto"
|
||||
className="about-container"
|
||||
>
|
||||
@@ -17,22 +17,22 @@ function AboutComponent() {
|
||||
src="/me.png"
|
||||
alt="Geoff Seemueller"
|
||||
borderRadius="full"
|
||||
boxSize={["120px", "150px"]}
|
||||
boxSize={['120px', '150px']}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
colSpan={1}
|
||||
maxW={["100%", "100%", "container.md"]}
|
||||
maxW={['100%', '100%', 'container.md']}
|
||||
justifySelf="center"
|
||||
minH={"100%"}
|
||||
minH={'100%'}
|
||||
>
|
||||
<Grid templateColumns="1fr" gap={4} overflowY={"auto"}>
|
||||
<Grid templateColumns="1fr" gap={4} overflowY={'auto'}>
|
||||
<GridItem>
|
||||
<Text fontSize={fontSize}>
|
||||
If you're interested in collaborating on innovative projects that
|
||||
push technological boundaries and create real value, I'd be keen
|
||||
to connect and explore potential opportunities.
|
||||
If you're interested in collaborating on innovative projects that push technological
|
||||
boundaries and create real value, I'd be keen to connect and explore potential
|
||||
opportunities.
|
||||
</Text>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
@@ -1,30 +1,26 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Box, Grid, GridItem } from "@chakra-ui/react";
|
||||
import ChatMessages from "./messages/ChatMessages";
|
||||
import ChatInput from "./input/ChatInput";
|
||||
import chatStore from "../../stores/ClientChatStore";
|
||||
import menuState from "../../stores/AppMenuStore";
|
||||
import WelcomeHome from "../WelcomeHome";
|
||||
import { Box, Grid, GridItem } from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import menuState from '../../stores/AppMenuStore';
|
||||
import chatStore from '../../stores/ClientChatStore';
|
||||
import WelcomeHome from '../WelcomeHome';
|
||||
|
||||
import ChatInput from './input/ChatInput';
|
||||
import ChatMessages from './messages/ChatMessages';
|
||||
|
||||
const Chat = observer(({ height, width }) => {
|
||||
const scrollRef = useRef();
|
||||
const [isAndroid, setIsAndroid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsAndroid(/android/i.test(window.navigator.userAgent));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateRows="1fr auto"
|
||||
templateColumns="1fr"
|
||||
height={height}
|
||||
width={width}
|
||||
gap={0}
|
||||
>
|
||||
<Grid templateRows="1fr auto" templateColumns="1fr" height={height} width={width} gap={0}>
|
||||
<GridItem alignSelf="center" hidden={!(chatStore.items.length < 1)}>
|
||||
<WelcomeHome visible={chatStore.items.length < 1} />
|
||||
</GridItem>
|
||||
@@ -35,32 +31,17 @@ const Chat = observer(({ height, width }) => {
|
||||
maxH="100%"
|
||||
ref={scrollRef}
|
||||
// If there are attachments, use "100px". Otherwise, use "128px" on Android, "73px" elsewhere.
|
||||
pb={
|
||||
isAndroid
|
||||
? "128px"
|
||||
: "73px"
|
||||
}
|
||||
pb={isAndroid ? '128px' : '73px'}
|
||||
alignSelf="flex-end"
|
||||
>
|
||||
<ChatMessages scrollRef={scrollRef} />
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
position="relative"
|
||||
bg="background.primary"
|
||||
zIndex={1000}
|
||||
width="100%"
|
||||
>
|
||||
<Box
|
||||
w="100%"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
mx="auto"
|
||||
hidden={menuState.isOpen}
|
||||
>
|
||||
<GridItem position="relative" bg="background.primary" zIndex={1000} width="100%">
|
||||
<Box w="100%" display="flex" justifyContent="center" mx="auto" hidden={menuState.isOpen}>
|
||||
<ChatInput
|
||||
input={chatStore.input}
|
||||
setInput={(value) => chatStore.setInput(value)}
|
||||
setInput={value => chatStore.setInput(value)}
|
||||
handleSendMessage={chatStore.sendMessage}
|
||||
isLoading={chatStore.isLoading}
|
||||
/>
|
||||
|
@@ -1,16 +1,17 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import clientChatStore from "../../stores/ClientChatStore";
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import clientChatStore from '../../stores/ClientChatStore';
|
||||
|
||||
export const IntermediateStepsComponent = observer(({ hidden }) => {
|
||||
return (
|
||||
<div hidden={hidden}>
|
||||
{clientChatStore.intermediateSteps.map((step, index) => {
|
||||
switch (step.kind) {
|
||||
case "web-search": {
|
||||
case 'web-search': {
|
||||
return <WebSearchResult key={index} data={step.data} />;
|
||||
}
|
||||
case "tool-result":
|
||||
case 'tool-result':
|
||||
return <ToolResult key={index} data={step.data} />;
|
||||
default:
|
||||
return <GenericStep key={index} data={step.data} />;
|
||||
@@ -45,7 +46,7 @@ export const GenericStep = ({ data }) => {
|
||||
return (
|
||||
<div className="generic-step">
|
||||
<h3>Generic Step</h3>
|
||||
<p>{data.description || "No additional information provided."}</p>
|
||||
<p>{data.description || 'No additional information provided.'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
Portal,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
const FlyoutSubMenu: React.FC<{
|
||||
title: string;
|
||||
@@ -23,15 +23,7 @@ const FlyoutSubMenu: React.FC<{
|
||||
parentIsOpen: boolean;
|
||||
setMenuState?: (state) => void;
|
||||
}> = observer(
|
||||
({
|
||||
title,
|
||||
flyoutMenuOptions,
|
||||
onClose,
|
||||
handleSelect,
|
||||
isSelected,
|
||||
parentIsOpen,
|
||||
setMenuState,
|
||||
}) => {
|
||||
({ title, flyoutMenuOptions, onClose, handleSelect, isSelected, parentIsOpen, setMenuState }) => {
|
||||
const { isOpen, onOpen, onClose: onSubMenuClose } = useDisclosure();
|
||||
|
||||
const menuRef = new useRef();
|
||||
@@ -41,9 +33,9 @@ const FlyoutSubMenu: React.FC<{
|
||||
placement="right-start"
|
||||
isOpen={isOpen && parentIsOpen}
|
||||
closeOnBlur={true}
|
||||
lazyBehavior={"keepMounted"}
|
||||
lazyBehavior={'keepMounted'}
|
||||
isLazy={true}
|
||||
onClose={(e) => {
|
||||
onClose={e => {
|
||||
onSubMenuClose();
|
||||
}}
|
||||
closeOnSelect={false}
|
||||
@@ -54,12 +46,12 @@ const FlyoutSubMenu: React.FC<{
|
||||
ref={menuRef}
|
||||
bg="background.tertiary"
|
||||
color="text.primary"
|
||||
_hover={{ bg: "rgba(0, 0, 0, 0.05)" }}
|
||||
_focus={{ bg: "rgba(0, 0, 0, 0.1)" }}
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
_focus={{ bg: 'rgba(0, 0, 0, 0.1)' }}
|
||||
>
|
||||
<HStack width={"100%"} justifyContent={"space-between"}>
|
||||
<HStack width={'100%'} justifyContent={'space-between'}>
|
||||
<Text>{title}</Text>
|
||||
<ChevronRight size={"1rem"} />
|
||||
<ChevronRight size={'1rem'} />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
@@ -67,7 +59,7 @@ const FlyoutSubMenu: React.FC<{
|
||||
key={title}
|
||||
maxHeight={56}
|
||||
overflowY="scroll"
|
||||
visibility={"visible"}
|
||||
visibility={'visible'}
|
||||
minWidth="180px"
|
||||
bg="background.tertiary"
|
||||
boxShadow="lg"
|
||||
@@ -77,43 +69,35 @@ const FlyoutSubMenu: React.FC<{
|
||||
left="100%"
|
||||
bottom={-10}
|
||||
sx={{
|
||||
"::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
'::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
"::-webkit-scrollbar-thumb": {
|
||||
background: "background.primary",
|
||||
borderRadius: "4px",
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
background: 'background.primary',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
"::-webkit-scrollbar-track": {
|
||||
background: "background.tertiary",
|
||||
'::-webkit-scrollbar-track': {
|
||||
background: 'background.tertiary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{flyoutMenuOptions.map((item, index) => (
|
||||
<Box key={"itemflybox" + index}>
|
||||
<Box key={'itemflybox' + index}>
|
||||
<MenuItem
|
||||
key={"itemfly" + index}
|
||||
key={'itemfly' + index}
|
||||
onClick={() => {
|
||||
onSubMenuClose();
|
||||
onClose();
|
||||
handleSelect(item);
|
||||
}}
|
||||
bg={
|
||||
isSelected(item)
|
||||
? "background.secondary"
|
||||
: "background.tertiary"
|
||||
}
|
||||
_hover={{ bg: "rgba(0, 0, 0, 0.05)" }}
|
||||
_focus={{ bg: "rgba(0, 0, 0, 0.1)" }}
|
||||
bg={isSelected(item) ? 'background.secondary' : 'background.tertiary'}
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
_focus={{ bg: 'rgba(0, 0, 0, 0.1)' }}
|
||||
>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
{index < flyoutMenuOptions.length - 1 && (
|
||||
<Divider
|
||||
key={item.name + "-divider"}
|
||||
color="text.tertiary"
|
||||
w={"100%"}
|
||||
/>
|
||||
<Divider key={item.name + '-divider'} color="text.tertiary" w={'100%'} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
@@ -1,197 +1,190 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
useDisclosure,
|
||||
useOutsideClick,
|
||||
} from "@chakra-ui/react";
|
||||
import {observer} from "mobx-react-lite";
|
||||
import {ChevronDown, Copy, RefreshCcw, Settings} from "lucide-react";
|
||||
import clientChatStore from "../../../stores/ClientChatStore";
|
||||
import FlyoutSubMenu from "./FlyoutSubMenu";
|
||||
import {useIsMobile} from "../../contexts/MobileContext";
|
||||
import {useIsMobile as useIsMobileUserAgent} from "../../../hooks/_IsMobileHook";
|
||||
import {formatConversationMarkdown} from "../lib/exportConversationAsMarkdown";
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
useDisclosure,
|
||||
useOutsideClick,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDown, Copy, RefreshCcw, Settings } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useIsMobile as useIsMobileUserAgent } from '../../../hooks/_IsMobileHook';
|
||||
import clientChatStore from '../../../stores/ClientChatStore';
|
||||
import { useIsMobile } from '../../contexts/MobileContext';
|
||||
import { formatConversationMarkdown } from '../lib/exportConversationAsMarkdown';
|
||||
|
||||
import FlyoutSubMenu from './FlyoutSubMenu';
|
||||
|
||||
export const MsM_commonButtonStyles = {
|
||||
bg: "transparent",
|
||||
color: "text.primary",
|
||||
borderRadius: "full",
|
||||
padding: 2,
|
||||
border: "none",
|
||||
_hover: {bg: "rgba(255, 255, 255, 0.2)"},
|
||||
_active: {bg: "rgba(255, 255, 255, 0.3)"},
|
||||
_focus: {boxShadow: "none"},
|
||||
bg: 'transparent',
|
||||
color: 'text.primary',
|
||||
borderRadius: 'full',
|
||||
padding: 2,
|
||||
border: 'none',
|
||||
_hover: { bg: 'rgba(255, 255, 255, 0.2)' },
|
||||
_active: { bg: 'rgba(255, 255, 255, 0.3)' },
|
||||
_focus: { boxShadow: 'none' },
|
||||
};
|
||||
|
||||
const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(
|
||||
({isDisabled}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUserAgent = useIsMobileUserAgent();
|
||||
const {
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onToggle,
|
||||
getDisclosureProps,
|
||||
getButtonProps,
|
||||
} = useDisclosure();
|
||||
const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(({ isDisabled }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUserAgent = useIsMobileUserAgent();
|
||||
const { isOpen, onOpen, onClose, onToggle, getDisclosureProps, getButtonProps } = useDisclosure();
|
||||
|
||||
const [controlledOpen, setControlledOpen] = useState<boolean>(false);
|
||||
const [supportedModels, setSupportedModels] = useState<any[]>([]);
|
||||
const [controlledOpen, setControlledOpen] = useState<boolean>(false);
|
||||
const [supportedModels, setSupportedModels] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setControlledOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
useEffect(() => {
|
||||
setControlledOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/models").then(response => response.json()).then((models) => {
|
||||
setSupportedModels(models);
|
||||
}).catch((err) => {
|
||||
console.error("Could not fetch models: ", err);
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch('/api/models')
|
||||
.then(response => response.json())
|
||||
.then(models => {
|
||||
setSupportedModels(models);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Could not fetch models: ', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [isOpen]);
|
||||
const handleCopyConversation = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(formatConversationMarkdown(clientChatStore.items))
|
||||
.then(() => {
|
||||
window.alert('Conversation copied to clipboard. \n\nPaste it somewhere safe!');
|
||||
onClose();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Could not copy text to clipboard: ', err);
|
||||
window.alert('Failed to copy conversation. Please try again.');
|
||||
});
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopyConversation = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(formatConversationMarkdown(clientChatStore.items))
|
||||
.then(() => {
|
||||
window.alert(
|
||||
"Conversation copied to clipboard. \n\nPaste it somewhere safe!",
|
||||
);
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Could not copy text to clipboard: ", err);
|
||||
window.alert("Failed to copy conversation. Please try again.");
|
||||
});
|
||||
}, [onClose]);
|
||||
async function selectModelFn({ name, value }) {
|
||||
clientChatStore.setModel(value);
|
||||
}
|
||||
|
||||
async function selectModelFn({name, value}) {
|
||||
clientChatStore.setModel(value);
|
||||
}
|
||||
function isSelectedModelFn({ name, value }) {
|
||||
return clientChatStore.model === value;
|
||||
}
|
||||
|
||||
function isSelectedModelFn({name, value}) {
|
||||
return clientChatStore.model === value;
|
||||
}
|
||||
const menuRef = useRef();
|
||||
const [menuState, setMenuState] = useState();
|
||||
|
||||
const menuRef = useRef();
|
||||
const [menuState, setMenuState] = useState();
|
||||
|
||||
useOutsideClick({
|
||||
enabled: !isMobile && isOpen,
|
||||
ref: menuRef,
|
||||
handler: () => {
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu
|
||||
isOpen={controlledOpen}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
autoSelect={false}
|
||||
closeOnSelect={false}
|
||||
closeOnBlur={isOpen && !isMobileUserAgent}
|
||||
isLazy={true}
|
||||
lazyBehavior={"unmount"}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
bg="text.accent"
|
||||
icon={<Settings size={20}/>}
|
||||
isDisabled={isDisabled}
|
||||
aria-label="Settings"
|
||||
_hover={{bg: "rgba(255, 255, 255, 0.2)"}}
|
||||
_focus={{boxShadow: "none"}}
|
||||
{...MsM_commonButtonStyles}
|
||||
/>
|
||||
) : (
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronDown size={16}/>}
|
||||
isDisabled={isDisabled}
|
||||
variant="ghost"
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
minW="auto"
|
||||
{...MsM_commonButtonStyles}
|
||||
>
|
||||
<Text noOfLines={1} maxW="100px" fontSize="sm">
|
||||
{clientChatStore.model}
|
||||
</Text>
|
||||
</MenuButton>
|
||||
)}
|
||||
<MenuList
|
||||
bg="background.tertiary"
|
||||
border="none"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
minW={"10rem"}
|
||||
ref={menuRef}
|
||||
>
|
||||
<FlyoutSubMenu
|
||||
title="Text Models"
|
||||
flyoutMenuOptions={supportedModels.map((modelData) => ({
|
||||
name: modelData.id.split('/').pop() || modelData.id,
|
||||
value: modelData.id
|
||||
}))}
|
||||
onClose={onClose}
|
||||
parentIsOpen={isOpen}
|
||||
setMenuState={setMenuState}
|
||||
handleSelect={selectModelFn}
|
||||
isSelected={isSelectedModelFn}
|
||||
/>
|
||||
<Divider color="text.tertiary"/>
|
||||
{/*Export conversation button*/}
|
||||
<MenuItem
|
||||
bg="background.tertiary"
|
||||
color="text.primary"
|
||||
onClick={handleCopyConversation}
|
||||
_hover={{bg: "rgba(0, 0, 0, 0.05)"}}
|
||||
_focus={{bg: "rgba(0, 0, 0, 0.1)"}}
|
||||
>
|
||||
<Flex align="center">
|
||||
<Copy size="16px" style={{marginRight: "8px"}}/>
|
||||
<Box>Export</Box>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
{/*New conversation button*/}
|
||||
<MenuItem
|
||||
bg="background.tertiary"
|
||||
color="text.primary"
|
||||
onClick={() => {
|
||||
clientChatStore.setActiveConversation("conversation:new");
|
||||
onClose();
|
||||
}}
|
||||
_hover={{bg: "rgba(0, 0, 0, 0.05)"}}
|
||||
_focus={{bg: "rgba(0, 0, 0, 0.1)"}}
|
||||
>
|
||||
<Flex align="center">
|
||||
<RefreshCcw size="16px" style={{marginRight: "8px"}}/>
|
||||
<Box>New</Box>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
useOutsideClick({
|
||||
enabled: !isMobile && isOpen,
|
||||
ref: menuRef,
|
||||
handler: () => {
|
||||
handleClose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu
|
||||
isOpen={controlledOpen}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
autoSelect={false}
|
||||
closeOnSelect={false}
|
||||
closeOnBlur={isOpen && !isMobileUserAgent}
|
||||
isLazy={true}
|
||||
lazyBehavior={'unmount'}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
bg="text.accent"
|
||||
icon={<Settings size={20} />}
|
||||
isDisabled={isDisabled}
|
||||
aria-label="Settings"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.2)' }}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
{...MsM_commonButtonStyles}
|
||||
/>
|
||||
) : (
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronDown size={16} />}
|
||||
isDisabled={isDisabled}
|
||||
variant="ghost"
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
minW="auto"
|
||||
{...MsM_commonButtonStyles}
|
||||
>
|
||||
<Text noOfLines={1} maxW="100px" fontSize="sm">
|
||||
{clientChatStore.model}
|
||||
</Text>
|
||||
</MenuButton>
|
||||
)}
|
||||
<MenuList
|
||||
bg="background.tertiary"
|
||||
border="none"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
minW={'10rem'}
|
||||
ref={menuRef}
|
||||
>
|
||||
<FlyoutSubMenu
|
||||
title="Text Models"
|
||||
flyoutMenuOptions={supportedModels.map(modelData => ({
|
||||
name: modelData.id.split('/').pop() || modelData.id,
|
||||
value: modelData.id,
|
||||
}))}
|
||||
onClose={onClose}
|
||||
parentIsOpen={isOpen}
|
||||
setMenuState={setMenuState}
|
||||
handleSelect={selectModelFn}
|
||||
isSelected={isSelectedModelFn}
|
||||
/>
|
||||
<Divider color="text.tertiary" />
|
||||
{/*Export conversation button*/}
|
||||
<MenuItem
|
||||
bg="background.tertiary"
|
||||
color="text.primary"
|
||||
onClick={handleCopyConversation}
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
_focus={{ bg: 'rgba(0, 0, 0, 0.1)' }}
|
||||
>
|
||||
<Flex align="center">
|
||||
<Copy size="16px" style={{ marginRight: '8px' }} />
|
||||
<Box>Export</Box>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
{/*New conversation button*/}
|
||||
<MenuItem
|
||||
bg="background.tertiary"
|
||||
color="text.primary"
|
||||
onClick={() => {
|
||||
clientChatStore.setActiveConversation('conversation:new');
|
||||
onClose();
|
||||
}}
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
_focus={{ bg: 'rgba(0, 0, 0, 0.1)' }}
|
||||
>
|
||||
<Flex align="center">
|
||||
<RefreshCcw size="16px" style={{ marginRight: '8px' }} />
|
||||
<Box>New</Box>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export default InputMenu;
|
||||
|
@@ -1,34 +1,28 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
GridItem,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import chatStore from "../../../stores/ClientChatStore";
|
||||
import InputMenu from "../input-menu/InputMenu";
|
||||
import InputTextarea from "./ChatInputTextArea";
|
||||
import SendButton from "./ChatInputSendButton";
|
||||
import { useMaxWidth } from "../../../hooks/useMaxWidth";
|
||||
import userOptionsStore from "../../../stores/UserOptionsStore";
|
||||
import { Box, Button, Grid, GridItem, useBreakpointValue } from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useMaxWidth } from '../../../hooks/useMaxWidth';
|
||||
import chatStore from '../../../stores/ClientChatStore';
|
||||
import userOptionsStore from '../../../stores/UserOptionsStore';
|
||||
import InputMenu from '../input-menu/InputMenu';
|
||||
|
||||
import SendButton from './ChatInputSendButton';
|
||||
import InputTextarea from './ChatInputTextArea';
|
||||
|
||||
const ChatInput = observer(() => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const maxWidth = useMaxWidth();
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
|
||||
const [containerHeight, setContainerHeight] = useState(56);
|
||||
const [containerBorderRadius, setContainerBorderRadius] = useState(9999);
|
||||
|
||||
const [shouldFollow, setShouldFollow] = useState<boolean>(
|
||||
userOptionsStore.followModeEnabled,
|
||||
);
|
||||
const [shouldFollow, setShouldFollow] = useState<boolean>(userOptionsStore.followModeEnabled);
|
||||
const [couldFollow, setCouldFollow] = useState<boolean>(chatStore.isLoading);
|
||||
|
||||
const [inputWidth, setInputWidth] = useState<string>("50%");
|
||||
const [inputWidth, setInputWidth] = useState<string>('50%');
|
||||
|
||||
useEffect(() => {
|
||||
setShouldFollow(chatStore.isLoading && userOptionsStore.followModeEnabled);
|
||||
@@ -42,8 +36,8 @@ const ChatInput = observer(() => {
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const newHeight = entry.target.clientHeight;
|
||||
setContainerHeight(newHeight);
|
||||
|
||||
@@ -63,20 +57,20 @@ const ChatInput = observer(() => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
chatStore.sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const inputMaxWidth = useBreakpointValue(
|
||||
{ base: "50rem", lg: "50rem", md: "80%", sm: "100vw" },
|
||||
{ base: '50rem', lg: '50rem', md: '80%', sm: '100vw' },
|
||||
{ ssr: true },
|
||||
);
|
||||
const inputMinWidth = useBreakpointValue({ lg: "40rem" }, { ssr: true });
|
||||
const inputMinWidth = useBreakpointValue({ lg: '40rem' }, { ssr: true });
|
||||
|
||||
useEffect(() => {
|
||||
setInputWidth("100%");
|
||||
setInputWidth('100%');
|
||||
}, [inputMaxWidth, inputMinWidth]);
|
||||
|
||||
return (
|
||||
@@ -105,12 +99,12 @@ const ChatInput = observer(() => {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(_) => {
|
||||
onClick={_ => {
|
||||
userOptionsStore.toggleFollowMode();
|
||||
}}
|
||||
isDisabled={!chatStore.isLoading}
|
||||
>
|
||||
{shouldFollow ? "Disable Follow Mode" : "Enable Follow Mode"}
|
||||
{shouldFollow ? 'Disable Follow Mode' : 'Enable Follow Mode'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -123,7 +117,7 @@ const ChatInput = observer(() => {
|
||||
gap={2}
|
||||
alignItems="center"
|
||||
style={{
|
||||
transition: "border-radius 0.2s ease",
|
||||
transition: 'border-radius 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<GridItem>
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Button } from "@chakra-ui/react";
|
||||
import clientChatStore from "../../../stores/ClientChatStore";
|
||||
import { CirclePause, Send } from "lucide-react";
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CirclePause, Send } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import clientChatStore from '../../../stores/ClientChatStore';
|
||||
|
||||
interface SendButtonProps {
|
||||
isLoading: boolean;
|
||||
@@ -13,25 +13,20 @@ interface SendButtonProps {
|
||||
}
|
||||
|
||||
const SendButton: React.FC<SendButtonProps> = ({ onClick }) => {
|
||||
const isDisabled =
|
||||
clientChatStore.input.trim().length === 0 && !clientChatStore.isLoading;
|
||||
const isDisabled = clientChatStore.input.trim().length === 0 && !clientChatStore.isLoading;
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) =>
|
||||
clientChatStore.isLoading
|
||||
? clientChatStore.stopIncomingMessage()
|
||||
: onClick(e)
|
||||
onClick={e =>
|
||||
clientChatStore.isLoading ? clientChatStore.stopIncomingMessage() : onClick(e)
|
||||
}
|
||||
bg="transparent"
|
||||
color={
|
||||
clientChatStore.input.trim().length <= 1 ? "brand.700" : "text.primary"
|
||||
}
|
||||
color={clientChatStore.input.trim().length <= 1 ? 'brand.700' : 'text.primary'}
|
||||
borderRadius="full"
|
||||
p={2}
|
||||
isDisabled={isDisabled}
|
||||
_hover={{ bg: !isDisabled ? "rgba(255, 255, 255, 0.2)" : "inherit" }}
|
||||
_active={{ bg: !isDisabled ? "rgba(255, 255, 255, 0.3)" : "inherit" }}
|
||||
_focus={{ boxShadow: "none" }}
|
||||
_hover={{ bg: !isDisabled ? 'rgba(255, 255, 255, 0.2)' : 'inherit' }}
|
||||
_active={{ bg: !isDisabled ? 'rgba(255, 255, 255, 0.3)' : 'inherit' }}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
>
|
||||
{clientChatStore.isLoading ? <MySpinner /> : <Send size={20} />}
|
||||
</Button>
|
||||
@@ -45,10 +40,10 @@ const MySpinner = ({ onClick }) => (
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<CirclePause color={"#F0F0F0"} size={24} onClick={onClick} />
|
||||
<CirclePause color={'#F0F0F0'} size={24} onClick={onClick} />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {observer} from "mobx-react-lite";
|
||||
import {Box, chakra, InputGroup,} from "@chakra-ui/react";
|
||||
import AutoResize from "react-textarea-autosize";
|
||||
import { Box, chakra, InputGroup } from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import AutoResize from 'react-textarea-autosize';
|
||||
|
||||
const AutoResizeTextArea = chakra(AutoResize);
|
||||
|
||||
@@ -15,10 +15,7 @@ interface InputTextAreaProps {
|
||||
|
||||
const InputTextArea: React.FC<InputTextAreaProps> = observer(
|
||||
({ inputRef, value, onChange, onKeyDown, isLoading }) => {
|
||||
|
||||
const [heightConstraint, setHeightConstraint] = useState<
|
||||
number | undefined
|
||||
>(10);
|
||||
const [heightConstraint, setHeightConstraint] = useState<number | undefined>(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (value.length > 10) {
|
||||
@@ -34,7 +31,6 @@ const InputTextArea: React.FC<InputTextAreaProps> = observer(
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
|
||||
{/* Input Area */}
|
||||
<InputGroup position="relative">
|
||||
<AutoResizeTextArea
|
||||
@@ -43,7 +39,7 @@ const InputTextArea: React.FC<InputTextAreaProps> = observer(
|
||||
value={value}
|
||||
height={heightConstraint}
|
||||
autoFocus
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
p={2}
|
||||
pr="8px"
|
||||
@@ -53,19 +49,19 @@ const InputTextArea: React.FC<InputTextAreaProps> = observer(
|
||||
borderRadius="20px"
|
||||
border="none"
|
||||
placeholder="Free my mind..."
|
||||
_placeholder={{ color: "gray.400" }}
|
||||
_placeholder={{ color: 'gray.400' }}
|
||||
_focus={{
|
||||
outline: "none",
|
||||
outline: 'none',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
minRows={1}
|
||||
maxRows={12}
|
||||
style={{
|
||||
touchAction: "none",
|
||||
resize: "none",
|
||||
overflowY: "auto",
|
||||
width: "100%",
|
||||
transition: "height 0.2s ease-in-out",
|
||||
touchAction: 'none',
|
||||
resize: 'none',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
transition: 'height 0.2s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import ChatInput from '../ChatInput';
|
||||
import userOptionsStore from '../../../../stores/UserOptionsStore';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import chatStore from '../../../../stores/ClientChatStore';
|
||||
import userOptionsStore from '../../../../stores/UserOptionsStore';
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
// Mock browser APIs
|
||||
class MockResizeObserver {
|
||||
@@ -85,7 +86,7 @@ vi.mock('./ChatInputTextArea', () => ({
|
||||
aria-label="Chat input"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
@@ -8,16 +8,16 @@ const SUPPORTED_MODELS_GROUPS = {
|
||||
groq: [
|
||||
// "mixtral-8x7b-32768",
|
||||
// "deepseek-r1-distill-llama-70b",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"gemma2-9b-it",
|
||||
"mistral-saba-24b",
|
||||
'meta-llama/llama-4-scout-17b-16e-instruct',
|
||||
'gemma2-9b-it',
|
||||
'mistral-saba-24b',
|
||||
// "qwen-2.5-32b",
|
||||
"llama-3.3-70b-versatile",
|
||||
'llama-3.3-70b-versatile',
|
||||
// "llama-3.3-70b-versatile"
|
||||
// "llama-3.1-70b-versatile",
|
||||
// "llama-3.3-70b-versatile"
|
||||
],
|
||||
cerebras: ["llama-3.3-70b"],
|
||||
cerebras: ['llama-3.3-70b'],
|
||||
claude: [
|
||||
// "claude-3-5-sonnet-20241022",
|
||||
// "claude-3-opus-20240229"
|
||||
@@ -44,34 +44,34 @@ const SUPPORTED_MODELS_GROUPS = {
|
||||
// "grok-beta"
|
||||
],
|
||||
cloudflareAI: [
|
||||
"llama-3.2-3b-instruct", // max_tokens
|
||||
"llama-3-8b-instruct", // max_tokens
|
||||
"llama-3.1-8b-instruct-fast", // max_tokens
|
||||
"deepseek-math-7b-instruct",
|
||||
"deepseek-coder-6.7b-instruct-awq",
|
||||
"hermes-2-pro-mistral-7b",
|
||||
"openhermes-2.5-mistral-7b-awq",
|
||||
"mistral-7b-instruct-v0.2",
|
||||
"neural-chat-7b-v3-1-awq",
|
||||
"openchat-3.5-0106",
|
||||
'llama-3.2-3b-instruct', // max_tokens
|
||||
'llama-3-8b-instruct', // max_tokens
|
||||
'llama-3.1-8b-instruct-fast', // max_tokens
|
||||
'deepseek-math-7b-instruct',
|
||||
'deepseek-coder-6.7b-instruct-awq',
|
||||
'hermes-2-pro-mistral-7b',
|
||||
'openhermes-2.5-mistral-7b-awq',
|
||||
'mistral-7b-instruct-v0.2',
|
||||
'neural-chat-7b-v3-1-awq',
|
||||
'openchat-3.5-0106',
|
||||
// "gemma-7b-it",
|
||||
],
|
||||
};
|
||||
|
||||
export type SupportedModel =
|
||||
| keyof typeof SUPPORTED_MODELS_GROUPS
|
||||
| (typeof SUPPORTED_MODELS_GROUPS)[keyof typeof SUPPORTED_MODELS_GROUPS][number];
|
||||
| keyof typeof SUPPORTED_MODELS_GROUPS
|
||||
| (typeof SUPPORTED_MODELS_GROUPS)[keyof typeof SUPPORTED_MODELS_GROUPS][number];
|
||||
|
||||
export type ModelFamily = keyof typeof SUPPORTED_MODELS_GROUPS;
|
||||
|
||||
function getModelFamily(model: string): ModelFamily | undefined {
|
||||
return Object.keys(SUPPORTED_MODELS_GROUPS)
|
||||
.filter((family) => {
|
||||
return SUPPORTED_MODELS_GROUPS[
|
||||
family as keyof typeof SUPPORTED_MODELS_GROUPS
|
||||
].includes(model.trim());
|
||||
})
|
||||
.at(0) as ModelFamily | undefined;
|
||||
.filter(family => {
|
||||
return SUPPORTED_MODELS_GROUPS[family as keyof typeof SUPPORTED_MODELS_GROUPS].includes(
|
||||
model.trim(),
|
||||
);
|
||||
})
|
||||
.at(0) as ModelFamily | undefined;
|
||||
}
|
||||
|
||||
const SUPPORTED_MODELS = [
|
||||
|
@@ -1,30 +1,30 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
function domPurify(dirty: string) {
|
||||
return DOMPurify.sanitize(dirty, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: [
|
||||
"b",
|
||||
"i",
|
||||
"u",
|
||||
"a",
|
||||
"p",
|
||||
"span",
|
||||
"div",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"td",
|
||||
"th",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"code",
|
||||
"pre",
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'a',
|
||||
'p',
|
||||
'span',
|
||||
'div',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'code',
|
||||
'pre',
|
||||
],
|
||||
ALLOWED_ATTR: ["href", "src", "alt", "title", "class", "style"],
|
||||
FORBID_TAGS: ["script", "iframe"],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
|
||||
FORBID_TAGS: ['script', 'iframe'],
|
||||
KEEP_CONTENT: true,
|
||||
SAFE_FOR_TEMPLATES: true,
|
||||
});
|
||||
|
@@ -1,18 +1,17 @@
|
||||
// Function to generate a Markdown representation of the current conversation
|
||||
import { type IMessage } from "../../../stores/ClientChatStore";
|
||||
import { type Instance } from "mobx-state-tree";
|
||||
import { type Instance } from 'mobx-state-tree';
|
||||
|
||||
export function formatConversationMarkdown(
|
||||
messages: Instance<typeof IMessage>[],
|
||||
): string {
|
||||
import { type IMessage } from '../../../stores/ClientChatStore';
|
||||
|
||||
export function formatConversationMarkdown(messages: Instance<typeof IMessage>[]): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
if (message.role === "user") {
|
||||
.map(message => {
|
||||
if (message.role === 'user') {
|
||||
return `**You**: ${message.content}`;
|
||||
} else if (message.role === "assistant") {
|
||||
} else if (message.role === 'assistant') {
|
||||
return `**Geoff's AI**: ${message.content}`;
|
||||
}
|
||||
return "";
|
||||
return '';
|
||||
})
|
||||
.join("\n\n");
|
||||
.join('\n\n');
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
|
||||
import MessageMarkdownRenderer from "./MessageMarkdownRenderer";
|
||||
import MessageMarkdownRenderer from './MessageMarkdownRenderer';
|
||||
|
||||
const ChatMessageContent = ({ content }) => {
|
||||
return <MessageMarkdownRenderer markdown={content} />;
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import {Box, Grid, GridItem} from "@chakra-ui/react";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import {observer} from "mobx-react-lite";
|
||||
import chatStore from "../../../stores/ClientChatStore";
|
||||
import {useIsMobile} from "../../contexts/MobileContext";
|
||||
import { Box, Grid, GridItem } from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import chatStore from '../../../stores/ClientChatStore';
|
||||
import { useIsMobile } from '../../contexts/MobileContext';
|
||||
|
||||
import MessageBubble from './MessageBubble';
|
||||
|
||||
interface ChatMessagesProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -13,11 +15,7 @@ const ChatMessages: React.FC<ChatMessagesProps> = observer(({ scrollRef }) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Box
|
||||
pt={isMobile ? 24 : undefined}
|
||||
overflowY={"scroll"}
|
||||
overflowX={"hidden"}
|
||||
>
|
||||
<Box pt={isMobile ? 24 : undefined} overflowY={'scroll'} overflowX={'hidden'}>
|
||||
<Grid
|
||||
fontFamily="Arial, sans-serif"
|
||||
templateColumns="1fr"
|
||||
|
@@ -1,43 +1,43 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Box, Flex, Text } from "@chakra-ui/react";
|
||||
import MessageRenderer from "./ChatMessageContent";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import MessageEditor from "./MessageEditorComponent";
|
||||
import UserMessageTools from "./UserMessageTools";
|
||||
import clientChatStore from "../../../stores/ClientChatStore";
|
||||
import UserOptionsStore from "../../../stores/UserOptionsStore";
|
||||
import MotionBox from "./MotionBox";
|
||||
import { Box, Flex, Text } from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import clientChatStore from '../../../stores/ClientChatStore';
|
||||
import UserOptionsStore from '../../../stores/UserOptionsStore';
|
||||
|
||||
import MessageRenderer from './ChatMessageContent';
|
||||
import MessageEditor from './MessageEditorComponent';
|
||||
import MotionBox from './MotionBox';
|
||||
import UserMessageTools from './UserMessageTools';
|
||||
|
||||
const LoadingDots = () => {
|
||||
return (
|
||||
<Flex>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<MotionBox
|
||||
key={i}
|
||||
width="8px"
|
||||
height="8px"
|
||||
borderRadius="50%"
|
||||
backgroundColor="text.primary"
|
||||
margin="0 4px"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.5, 1, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{[0, 1, 2].map(i => (
|
||||
<MotionBox
|
||||
key={i}
|
||||
width="8px"
|
||||
height="8px"
|
||||
borderRadius="50%"
|
||||
backgroundColor="text.primary"
|
||||
margin="0 4px"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.5, 1, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function renderMessage(msg: any) {
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<Text as="p" fontSize="sm" lineHeight="short" color="text.primary">
|
||||
{msg.content}
|
||||
@@ -50,8 +50,8 @@ function renderMessage(msg: any) {
|
||||
const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isUser = msg.role === "user";
|
||||
const senderName = isUser ? "You" : "Geoff's AI";
|
||||
const isUser = msg.role === 'user';
|
||||
const senderName = isUser ? 'You' : "Geoff's AI";
|
||||
const isLoading = !msg.content || !(msg.content.trim().length > 0);
|
||||
const messageRef = useRef();
|
||||
|
||||
@@ -64,10 +64,15 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (clientChatStore.items.length > 0 && clientChatStore.isLoading && UserOptionsStore.followModeEnabled) { // Refine condition
|
||||
if (
|
||||
clientChatStore.items.length > 0 &&
|
||||
clientChatStore.isLoading &&
|
||||
UserOptionsStore.followModeEnabled
|
||||
) {
|
||||
// Refine condition
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "auto",
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -75,7 +80,7 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
return (
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems={isUser ? "flex-end" : "flex-start"}
|
||||
alignItems={isUser ? 'flex-end' : 'flex-start'}
|
||||
role="listitem"
|
||||
flex={0}
|
||||
aria-label={`Message from ${senderName}`}
|
||||
@@ -85,19 +90,19 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="text.tertiary"
|
||||
textAlign={isUser ? "right" : "left"}
|
||||
alignSelf={isUser ? "flex-end" : "flex-start"}
|
||||
textAlign={isUser ? 'right' : 'left'}
|
||||
alignSelf={isUser ? 'flex-end' : 'flex-start'}
|
||||
mb={1}
|
||||
>
|
||||
{senderName}
|
||||
</Text>
|
||||
|
||||
<MotionBox
|
||||
minW={{ base: "99%", sm: "99%", lg: isUser ? "55%" : "60%" }}
|
||||
maxW={{ base: "99%", sm: "99%", lg: isUser ? "65%" : "65%" }}
|
||||
minW={{ base: '99%', sm: '99%', lg: isUser ? '55%' : '60%' }}
|
||||
maxW={{ base: '99%', sm: '99%', lg: isUser ? '65%' : '65%' }}
|
||||
p={3}
|
||||
borderRadius="1.5em"
|
||||
bg={isUser ? "#0A84FF" : "#3A3A3C"}
|
||||
bg={isUser ? '#0A84FF' : '#3A3A3C'}
|
||||
color="text.primary"
|
||||
textAlign="left"
|
||||
boxShadow="0 2px 4px rgba(0, 0, 0, 0.1)"
|
||||
@@ -115,10 +120,10 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
whiteSpace="pre-wrap"
|
||||
ref={messageRef}
|
||||
sx={{
|
||||
"pre, code": {
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowX: "auto",
|
||||
'pre, code': {
|
||||
maxWidth: '100%',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -139,9 +144,7 @@ const MessageBubble = observer(({ msg, scrollRef }) => {
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{isHovered && !isEditing && (
|
||||
<UserMessageTools message={msg} onEdit={handleEdit} />
|
||||
)}
|
||||
{isHovered && !isEditing && <UserMessageTools message={msg} onEdit={handleEdit} />}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import React, {type KeyboardEvent, useEffect } from "react";
|
||||
import { Box, Flex, IconButton, Textarea } from "@chakra-ui/react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { type Instance } from "mobx-state-tree";
|
||||
import Message from "../../../models/Message";
|
||||
import messageEditorStore from "../../../stores/MessageEditorStore";
|
||||
import { Box, Flex, IconButton, Textarea } from '@chakra-ui/react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { type Instance } from 'mobx-state-tree';
|
||||
import React, { type KeyboardEvent, useEffect } from 'react';
|
||||
|
||||
import Message from '../../../models/Message';
|
||||
import messageEditorStore from '../../../stores/MessageEditorStore';
|
||||
|
||||
interface MessageEditorProps {
|
||||
message: Instance<typeof Message>;
|
||||
@@ -30,15 +31,13 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
@@ -48,14 +47,14 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
<Box width="100%">
|
||||
<Textarea
|
||||
value={messageEditorStore.editedContent}
|
||||
onChange={(e) => messageEditorStore.setEditedContent(e.target.value)}
|
||||
onChange={e => messageEditorStore.setEditedContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
minHeight="100px"
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
_hover={{ borderColor: "whiteAlpha.400" }}
|
||||
_focus={{ borderColor: "brand.100", boxShadow: "none" }}
|
||||
_hover={{ borderColor: 'whiteAlpha.400' }}
|
||||
_focus={{ borderColor: 'brand.100', boxShadow: 'none' }}
|
||||
resize="vertical"
|
||||
color="text.primary"
|
||||
/>
|
||||
@@ -66,7 +65,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
onClick={handleCancel}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={"accent.danger"}
|
||||
color={'accent.danger'}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Save edit"
|
||||
@@ -74,7 +73,7 @@ const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={"accent.confirm"}
|
||||
color={'accent.confirm'}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
@@ -17,13 +15,15 @@ import {
|
||||
Thead,
|
||||
Tr,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import { marked } from "marked";
|
||||
import CodeBlock from "../../code/CodeBlock";
|
||||
import ImageWithFallback from "../../markdown/ImageWithFallback";
|
||||
import markedKatex from "marked-katex-extension";
|
||||
import katex from "katex";
|
||||
import domPurify from "../lib/domPurify";
|
||||
} from '@chakra-ui/react';
|
||||
import katex from 'katex';
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import React from 'react';
|
||||
|
||||
import CodeBlock from '../../code/CodeBlock';
|
||||
import ImageWithFallback from '../../markdown/ImageWithFallback';
|
||||
import domPurify from '../lib/domPurify';
|
||||
|
||||
try {
|
||||
if (localStorage) {
|
||||
@@ -34,11 +34,13 @@ try {
|
||||
throwOnError: false,
|
||||
strict: true,
|
||||
colorIsTextColor: true,
|
||||
errorColor: "red",
|
||||
errorColor: 'red',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
// Silently ignore errors in marked setup - fallback to default behavior
|
||||
}
|
||||
|
||||
const MemoizedCodeBlock = React.memo(CodeBlock);
|
||||
|
||||
@@ -49,32 +51,29 @@ const MemoizedCodeBlock = React.memo(CodeBlock);
|
||||
const getHeadingProps = (depth: number) => {
|
||||
switch (depth) {
|
||||
case 1:
|
||||
return { as: "h1", size: "xl", mt: 4, mb: 2 };
|
||||
return { as: 'h1', size: 'xl', mt: 4, mb: 2 };
|
||||
case 2:
|
||||
return { as: "h2", size: "lg", mt: 3, mb: 2 };
|
||||
return { as: 'h2', size: 'lg', mt: 3, mb: 2 };
|
||||
case 3:
|
||||
return { as: "h3", size: "md", mt: 2, mb: 1 };
|
||||
return { as: 'h3', size: 'md', mt: 2, mb: 1 };
|
||||
case 4:
|
||||
return { as: "h4", size: "sm", mt: 2, mb: 1 };
|
||||
return { as: 'h4', size: 'sm', mt: 2, mb: 1 };
|
||||
case 5:
|
||||
return { as: "h5", size: "sm", mt: 2, mb: 1 };
|
||||
return { as: 'h5', size: 'sm', mt: 2, mb: 1 };
|
||||
case 6:
|
||||
return { as: "h6", size: "xs", mt: 2, mb: 1 };
|
||||
return { as: 'h6', size: 'xs', mt: 2, mb: 1 };
|
||||
default:
|
||||
return { as: `h${depth}`, size: "md", mt: 2, mb: 1 };
|
||||
return { as: `h${depth}`, size: 'md', mt: 2, mb: 1 };
|
||||
}
|
||||
};
|
||||
|
||||
interface TableToken extends marked.Tokens.Table {
|
||||
align: Array<"center" | "left" | "right" | null>;
|
||||
align: Array<'center' | 'left' | 'right' | null>;
|
||||
header: (string | marked.Tokens.TableCell)[];
|
||||
rows: (string | marked.Tokens.TableCell)[][];
|
||||
}
|
||||
|
||||
const CustomHeading: React.FC<{ text: string; depth: number }> = ({
|
||||
text,
|
||||
depth,
|
||||
}) => {
|
||||
const CustomHeading: React.FC<{ text: string; depth: number }> = ({ text, depth }) => {
|
||||
const headingProps = getHeadingProps(depth);
|
||||
return (
|
||||
<Heading {...headingProps} wordBreak="break-word" maxWidth="100%">
|
||||
@@ -83,9 +82,7 @@ const CustomHeading: React.FC<{ text: string; depth: number }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Text
|
||||
as="p"
|
||||
@@ -100,9 +97,7 @@ const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomBlockquote: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const CustomBlockquote: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Box
|
||||
as="blockquote"
|
||||
@@ -120,16 +115,9 @@ const CustomBlockquote: React.FC<{ children: React.ReactNode }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CustomCodeBlock: React.FC<{ code: string; language?: string }> = ({
|
||||
code,
|
||||
language,
|
||||
}) => {
|
||||
const CustomCodeBlock: React.FC<{ code: string; language?: string }> = ({ code, language }) => {
|
||||
return (
|
||||
<MemoizedCodeBlock
|
||||
language={language}
|
||||
code={code}
|
||||
onRenderComplete={() => Promise.resolve()}
|
||||
/>
|
||||
<MemoizedCodeBlock language={language} code={code} onRenderComplete={() => Promise.resolve()} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -141,10 +129,10 @@ const CustomList: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ ordered, start, children }) => {
|
||||
const commonStyles = {
|
||||
fontSize: "sm",
|
||||
wordBreak: "break-word" as const,
|
||||
maxWidth: "100%" as const,
|
||||
stylePosition: "outside" as const,
|
||||
fontSize: 'sm',
|
||||
wordBreak: 'break-word' as const,
|
||||
maxWidth: '100%' as const,
|
||||
stylePosition: 'outside' as const,
|
||||
mb: 2,
|
||||
pl: 4,
|
||||
};
|
||||
@@ -166,16 +154,13 @@ const CustomListItem: React.FC<{
|
||||
return <ListItem mb={1}>{children}</ListItem>;
|
||||
};
|
||||
|
||||
const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({
|
||||
math,
|
||||
displayMode,
|
||||
}) => {
|
||||
const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({ math, displayMode }) => {
|
||||
const renderedMath = katex.renderToString(math, { displayMode });
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? "block" : "inline"}
|
||||
display={displayMode ? 'block' : 'inline'}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
@@ -188,23 +173,17 @@ const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({
|
||||
|
||||
const CustomTable: React.FC<{
|
||||
header: React.ReactNode[];
|
||||
align: Array<"center" | "left" | "right" | null>;
|
||||
align: Array<'center' | 'left' | 'right' | null>;
|
||||
rows: React.ReactNode[][];
|
||||
}> = ({ header, align, rows }) => {
|
||||
return (
|
||||
<Table
|
||||
variant="simple"
|
||||
size="sm"
|
||||
my={4}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Table variant="simple" size="sm" my={4} borderRadius="md" overflow="hidden">
|
||||
<Thead bg="background.secondary">
|
||||
<Tr>
|
||||
{header.map((cell, i) => (
|
||||
<Th
|
||||
key={i}
|
||||
textAlign={align[i] || "left"}
|
||||
textAlign={align[i] || 'left'}
|
||||
fontWeight="bold"
|
||||
p={2}
|
||||
minW={16}
|
||||
@@ -219,12 +198,7 @@ const CustomTable: React.FC<{
|
||||
{rows.map((row, rIndex) => (
|
||||
<Tr key={rIndex}>
|
||||
{row.map((cell, cIndex) => (
|
||||
<Td
|
||||
key={cIndex}
|
||||
textAlign={align[cIndex] || "left"}
|
||||
p={2}
|
||||
wordBreak="break-word"
|
||||
>
|
||||
<Td key={cIndex} textAlign={align[cIndex] || 'left'} p={2} wordBreak="break-word">
|
||||
{cell}
|
||||
</Td>
|
||||
))}
|
||||
@@ -241,13 +215,7 @@ const CustomHtmlBlock: React.FC<{ content: string }> = ({ content }) => {
|
||||
|
||||
const CustomText: React.FC<{ text: React.ReactNode }> = ({ text }) => {
|
||||
return (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
as="span"
|
||||
>
|
||||
<Text fontSize="sm" lineHeight="short" wordBreak="break-word" maxWidth="100%" as="span">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
@@ -262,13 +230,7 @@ const CustomStrong: React.FC<CustomStrongProps> = ({ children }) => {
|
||||
|
||||
const CustomEm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Text
|
||||
as="em"
|
||||
fontStyle="italic"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
display="inline"
|
||||
>
|
||||
<Text as="em" fontStyle="italic" lineHeight="short" wordBreak="break-word" display="inline">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
@@ -289,7 +251,7 @@ const CustomDel: React.FC<{ text: string }> = ({ text }) => {
|
||||
};
|
||||
|
||||
const CustomCodeSpan: React.FC<{ code: string }> = ({ code }) => {
|
||||
const bg = useColorModeValue("gray.100", "gray.800");
|
||||
const bg = useColorModeValue('gray.100', 'gray.800');
|
||||
return (
|
||||
<Code
|
||||
fontSize="sm"
|
||||
@@ -312,13 +274,13 @@ const CustomMath: React.FC<{ math: string; displayMode?: boolean }> = ({
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? "block" : "inline"}
|
||||
display={displayMode ? 'block' : 'inline'}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
overflow="auto"
|
||||
maxWidth="100%"
|
||||
className={`math ${displayMode ? "math-display" : "math-inline"}`}
|
||||
className={`math ${displayMode ? 'math-display' : 'math-inline'}`}
|
||||
>
|
||||
{math}
|
||||
</Box>
|
||||
@@ -336,8 +298,8 @@ const CustomLink: React.FC<{
|
||||
title={title}
|
||||
isExternal
|
||||
sx={{
|
||||
"& span": {
|
||||
color: "text.link",
|
||||
'& span': {
|
||||
color: 'text.link',
|
||||
},
|
||||
}}
|
||||
maxWidth="100%"
|
||||
@@ -379,46 +341,34 @@ function parseTokens(tokens: marked.Token[]): JSX.Element[] {
|
||||
|
||||
tokens.forEach((token, i) => {
|
||||
switch (token.type) {
|
||||
case "heading":
|
||||
output.push(
|
||||
<CustomHeading key={i} text={token.text} depth={token.depth} />,
|
||||
);
|
||||
case 'heading':
|
||||
output.push(<CustomHeading key={i} text={token.text} depth={token.depth} />);
|
||||
break;
|
||||
|
||||
case "paragraph": {
|
||||
const parsedContent = token.tokens
|
||||
? parseTokens(token.tokens)
|
||||
: token.text;
|
||||
case 'paragraph': {
|
||||
const parsedContent = token.tokens ? parseTokens(token.tokens) : token.text;
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(
|
||||
<CustomParagraph key={i}>{parsedContent}</CustomParagraph>,
|
||||
);
|
||||
blockquoteContent.push(<CustomParagraph key={i}>{parsedContent}</CustomParagraph>);
|
||||
} else {
|
||||
output.push(
|
||||
<CustomParagraph key={i}>{parsedContent}</CustomParagraph>,
|
||||
);
|
||||
output.push(<CustomParagraph key={i}>{parsedContent}</CustomParagraph>);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "br":
|
||||
case 'br':
|
||||
output.push(<br key={i} />);
|
||||
break;
|
||||
case "escape": {
|
||||
case 'escape': {
|
||||
break;
|
||||
}
|
||||
case "blockquote_start":
|
||||
case 'blockquote_start':
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
|
||||
case "blockquote_end":
|
||||
output.push(
|
||||
<CustomBlockquote key={i}>
|
||||
{parseTokens(blockquoteContent)}
|
||||
</CustomBlockquote>,
|
||||
);
|
||||
case 'blockquote_end':
|
||||
output.push(<CustomBlockquote key={i}>{parseTokens(blockquoteContent)}</CustomBlockquote>);
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
case "blockquote": {
|
||||
case 'blockquote': {
|
||||
output.push(
|
||||
<CustomBlockquote key={i}>
|
||||
{token.tokens ? parseTokens(token.tokens) : null}
|
||||
@@ -426,44 +376,30 @@ function parseTokens(tokens: marked.Token[]): JSX.Element[] {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "math":
|
||||
output.push(
|
||||
<CustomMath key={i} math={(token as any).value} displayMode={true} />,
|
||||
);
|
||||
case 'math':
|
||||
output.push(<CustomMath key={i} math={(token as any).value} displayMode={true} />);
|
||||
break;
|
||||
|
||||
case "inlineMath":
|
||||
output.push(
|
||||
<CustomMath
|
||||
key={i}
|
||||
math={(token as any).value}
|
||||
displayMode={false}
|
||||
/>,
|
||||
);
|
||||
case 'inlineMath':
|
||||
output.push(<CustomMath key={i} math={(token as any).value} displayMode={false} />);
|
||||
break;
|
||||
case "inlineKatex":
|
||||
case "blockKatex": {
|
||||
case 'inlineKatex':
|
||||
case 'blockKatex': {
|
||||
const katexToken = token as any;
|
||||
output.push(
|
||||
<CustomKatex
|
||||
key={i}
|
||||
math={katexToken.text}
|
||||
displayMode={katexToken.displayMode}
|
||||
/>,
|
||||
<CustomKatex key={i} math={katexToken.text} displayMode={katexToken.displayMode} />,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "code":
|
||||
output.push(
|
||||
<CustomCodeBlock key={i} code={token.text} language={token.lang} />,
|
||||
);
|
||||
case 'code':
|
||||
output.push(<CustomCodeBlock key={i} code={token.text} language={token.lang} />);
|
||||
break;
|
||||
|
||||
case "hr":
|
||||
case 'hr':
|
||||
output.push(<CustomHr key={i} />);
|
||||
break;
|
||||
|
||||
case "list": {
|
||||
case 'list': {
|
||||
const { ordered, start, items } = token;
|
||||
const listItems = items.map((listItem, idx) => {
|
||||
const nestedContent = parseTokens(listItem.tokens);
|
||||
@@ -477,53 +413,43 @@ function parseTokens(tokens: marked.Token[]): JSX.Element[] {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "table": {
|
||||
case 'table': {
|
||||
const tableToken = token as TableToken;
|
||||
|
||||
output.push(
|
||||
<CustomTable
|
||||
key={i}
|
||||
header={tableToken.header.map((cell) =>
|
||||
typeof cell === "string" ? cell : parseTokens(cell.tokens || []),
|
||||
header={tableToken.header.map(cell =>
|
||||
typeof cell === 'string' ? cell : parseTokens(cell.tokens || []),
|
||||
)}
|
||||
align={tableToken.align}
|
||||
rows={tableToken.rows.map((row) =>
|
||||
row.map((cell) =>
|
||||
typeof cell === "string"
|
||||
? cell
|
||||
: parseTokens(cell.tokens || []),
|
||||
),
|
||||
rows={tableToken.rows.map(row =>
|
||||
row.map(cell => (typeof cell === 'string' ? cell : parseTokens(cell.tokens || []))),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "html":
|
||||
case 'html':
|
||||
output.push(<CustomHtmlBlock key={i} content={token.text} />);
|
||||
break;
|
||||
case "def":
|
||||
case "space":
|
||||
case 'def':
|
||||
case 'space':
|
||||
break;
|
||||
case "strong":
|
||||
output.push(
|
||||
<CustomStrong key={i}>
|
||||
{parseTokens(token.tokens || [])}
|
||||
</CustomStrong>,
|
||||
);
|
||||
case 'strong':
|
||||
output.push(<CustomStrong key={i}>{parseTokens(token.tokens || [])}</CustomStrong>);
|
||||
break;
|
||||
case "em":
|
||||
case 'em':
|
||||
output.push(
|
||||
<CustomEm key={i}>
|
||||
{token.tokens ? parseTokens(token.tokens) : token.text}
|
||||
</CustomEm>,
|
||||
<CustomEm key={i}>{token.tokens ? parseTokens(token.tokens) : token.text}</CustomEm>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "codespan":
|
||||
case 'codespan':
|
||||
output.push(<CustomCodeSpan key={i} code={token.text} />);
|
||||
break;
|
||||
|
||||
case "link":
|
||||
case 'link':
|
||||
output.push(
|
||||
<CustomLink key={i} href={token.href} title={token.title}>
|
||||
{token.tokens ? parseTokens(token.tokens) : token.text}
|
||||
@@ -531,33 +457,24 @@ function parseTokens(tokens: marked.Token[]): JSX.Element[] {
|
||||
);
|
||||
break;
|
||||
|
||||
case "image":
|
||||
case 'image':
|
||||
output.push(
|
||||
<CustomImage
|
||||
key={i}
|
||||
href={token.href}
|
||||
title={token.title}
|
||||
text={token.text}
|
||||
/>,
|
||||
<CustomImage key={i} href={token.href} title={token.title} text={token.text} />,
|
||||
);
|
||||
break;
|
||||
|
||||
case "text": {
|
||||
const parsedContent = token.tokens
|
||||
? parseTokens(token.tokens)
|
||||
: token.text;
|
||||
case 'text': {
|
||||
const parsedContent = token.tokens ? parseTokens(token.tokens) : token.text;
|
||||
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(
|
||||
<React.Fragment key={i}>{parsedContent}</React.Fragment>,
|
||||
);
|
||||
blockquoteContent.push(<React.Fragment key={i}>{parsedContent}</React.Fragment>);
|
||||
} else {
|
||||
output.push(<CustomText key={i} text={parsedContent} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("Unhandled token type:", token.type, token);
|
||||
console.warn('Unhandled token type:', token.type, token);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -1,13 +1,12 @@
|
||||
import React from "react";
|
||||
import {renderMessageMarkdown} from "./MessageMarkdown";
|
||||
import React from 'react';
|
||||
|
||||
import { renderMessageMarkdown } from './MessageMarkdown';
|
||||
|
||||
interface CustomMarkdownRendererProps {
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
const MessageMarkdownRenderer: React.FC<CustomMarkdownRendererProps> = ({
|
||||
markdown,
|
||||
}) => {
|
||||
const MessageMarkdownRenderer: React.FC<CustomMarkdownRendererProps> = ({ markdown }) => {
|
||||
return <div>{renderMessageMarkdown(markdown)}</div>;
|
||||
};
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import {motion} from "framer-motion";
|
||||
import {Box} from "@chakra-ui/react";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default motion(Box);
|
||||
export default motion(Box);
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { IconButton } from "@chakra-ui/react";
|
||||
import { Edit2Icon } from "lucide-react";
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { Edit2Icon } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const UserMessageTools = observer(({ disabled = false, message, onEdit }) => (
|
||||
<IconButton
|
||||
@@ -8,26 +8,26 @@ const UserMessageTools = observer(({ disabled = false, message, onEdit }) => (
|
||||
color="text.primary"
|
||||
aria-label="Edit message"
|
||||
title="Edit message"
|
||||
icon={<Edit2Icon size={"1em"} />}
|
||||
icon={<Edit2Icon size={'1em'} />}
|
||||
onClick={() => onEdit(message)}
|
||||
_active={{
|
||||
bg: "transparent",
|
||||
bg: 'transparent',
|
||||
svg: {
|
||||
stroke: "brand.100",
|
||||
transition: "stroke 0.3s ease-in-out",
|
||||
stroke: 'brand.100',
|
||||
transition: 'stroke 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
_hover={{
|
||||
bg: "transparent",
|
||||
bg: 'transparent',
|
||||
svg: {
|
||||
stroke: "accent.secondary",
|
||||
transition: "stroke 0.3s ease-in-out",
|
||||
stroke: 'accent.secondary',
|
||||
transition: 'stroke 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
_focus={{ boxShadow: "none" }}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@@ -1,14 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import messageEditorStore from '../../../../stores/MessageEditorStore';
|
||||
import MessageBubble from '../MessageBubble';
|
||||
import messageEditorStore from "../../../../stores/MessageEditorStore";
|
||||
|
||||
// Mock browser APIs
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
// Add ResizeObserver to the global object
|
||||
@@ -16,140 +17,140 @@ global.ResizeObserver = MockResizeObserver;
|
||||
|
||||
// Mock the Message model
|
||||
vi.mock('../../../../models/Message', () => ({
|
||||
default: {
|
||||
// This is needed for the Instance<typeof Message> type
|
||||
}
|
||||
default: {
|
||||
// This is needed for the Instance<typeof Message> type
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('../../../../stores/ClientChatStore', () => ({
|
||||
default: {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
editMessage: vi.fn().mockReturnValue(true)
|
||||
}
|
||||
default: {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
editMessage: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../stores/UserOptionsStore', () => ({
|
||||
default: {
|
||||
followModeEnabled: false,
|
||||
setFollowModeEnabled: vi.fn()
|
||||
}
|
||||
default: {
|
||||
followModeEnabled: false,
|
||||
setFollowModeEnabled: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the MessageEditorStore
|
||||
vi.mock('../../../../stores/MessageEditorStore', () => ({
|
||||
default: {
|
||||
editedContent: 'Test message',
|
||||
setEditedContent: vi.fn(),
|
||||
setMessage: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
handleSave: vi.fn().mockImplementation(function() {
|
||||
// Use the mocked messageEditorStore from the import
|
||||
messageEditorStore.onCancel();
|
||||
return Promise.resolve();
|
||||
})
|
||||
}
|
||||
default: {
|
||||
editedContent: 'Test message',
|
||||
setEditedContent: vi.fn(),
|
||||
setMessage: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
handleSave: vi.fn().mockImplementation(function () {
|
||||
// Use the mocked messageEditorStore from the import
|
||||
messageEditorStore.onCancel();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the MessageRenderer component
|
||||
vi.mock('../ChatMessageContent', () => ({
|
||||
default: ({ content }) => <div data-testid="message-content">{content}</div>
|
||||
default: ({ content }) => <div data-testid="message-content">{content}</div>,
|
||||
}));
|
||||
|
||||
// Mock the UserMessageTools component
|
||||
vi.mock('../UserMessageTools', () => ({
|
||||
default: ({ message, onEdit }) => (
|
||||
<button data-testid="edit-button" onClick={() => onEdit(message)}>
|
||||
Edit
|
||||
</button>
|
||||
)
|
||||
default: ({ message, onEdit }) => (
|
||||
<button data-testid="edit-button" onClick={() => onEdit(message)}>
|
||||
Edit
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../MotionBox", async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
vi.mock('../MotionBox', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
|
||||
return { default: {
|
||||
...actual.default,
|
||||
div: (props: any) => React.createElement('div', props, props.children),
|
||||
motion: (props: any) => React.createElement('div', props, props.children),
|
||||
|
||||
}
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
...actual.default,
|
||||
div: (props: any) => React.createElement('div', props, props.children),
|
||||
motion: (props: any) => React.createElement('div', props, props.children),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('MessageBubble', () => {
|
||||
const mockScrollRef = { current: { scrollTo: vi.fn() } };
|
||||
const mockUserMessage = {
|
||||
role: 'user',
|
||||
content: 'Test message'
|
||||
};
|
||||
const mockAssistantMessage = {
|
||||
role: 'assistant',
|
||||
content: 'Assistant response'
|
||||
};
|
||||
const mockScrollRef = { current: { scrollTo: vi.fn() } };
|
||||
const mockUserMessage = {
|
||||
role: 'user',
|
||||
content: 'Test message',
|
||||
};
|
||||
const mockAssistantMessage = {
|
||||
role: 'assistant',
|
||||
content: 'Assistant response',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render user message correctly', () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
expect(screen.getByText('You')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render assistant message correctly', () => {
|
||||
render(<MessageBubble msg={mockAssistantMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
expect(screen.getByText("Geoff's AI")).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-content')).toHaveTextContent('Assistant response');
|
||||
});
|
||||
|
||||
it('should show edit button on hover for user messages', async () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Simulate hover
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
|
||||
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show editor when edit button is clicked', () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Simulate hover and click edit
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
fireEvent.click(screen.getByTestId('edit-button'));
|
||||
|
||||
// Check if the textarea is rendered (part of MessageEditor)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide editor after message is edited and saved', async () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Show the editor
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
fireEvent.click(screen.getByTestId('edit-button'));
|
||||
|
||||
// Verify editor is shown
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByLabelText('Save edit');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for the editor to disappear
|
||||
await waitFor(() => {
|
||||
// Check that the editor is no longer visible
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
// And the message content is visible again
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render user message correctly', () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
expect(screen.getByText('You')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render assistant message correctly', () => {
|
||||
render(<MessageBubble msg={mockAssistantMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
expect(screen.getByText("Geoff's AI")).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-content')).toHaveTextContent('Assistant response');
|
||||
});
|
||||
|
||||
it('should show edit button on hover for user messages', async () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Simulate hover
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
|
||||
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show editor when edit button is clicked', () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Simulate hover and click edit
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
fireEvent.click(screen.getByTestId('edit-button'));
|
||||
|
||||
// Check if the textarea is rendered (part of MessageEditor)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide editor after message is edited and saved', async () => {
|
||||
render(<MessageBubble msg={mockUserMessage} scrollRef={mockScrollRef} />);
|
||||
|
||||
// Show the editor
|
||||
fireEvent.mouseEnter(screen.getByRole('listitem'));
|
||||
fireEvent.click(screen.getByTestId('edit-button'));
|
||||
|
||||
// Verify editor is shown
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
// Find and click the save button
|
||||
const saveButton = screen.getByLabelText('Save edit');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for the editor to disappear
|
||||
await waitFor(() => {
|
||||
// Check that the editor is no longer visible
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
// And the message content is visible again
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify that handleSave was called
|
||||
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||
});
|
||||
// Verify that handleSave was called
|
||||
expect(messageEditorStore.handleSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@@ -1,27 +1,27 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import MessageEditor from '../MessageEditorComponent';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Import the mocked stores
|
||||
import clientChatStore from '../../../../stores/ClientChatStore';
|
||||
import messageEditorStore from '../../../../stores/MessageEditorStore';
|
||||
import MessageEditor from '../MessageEditorComponent';
|
||||
|
||||
// Mock the Message model
|
||||
vi.mock('../../../../models/Message', () => {
|
||||
return {
|
||||
default: {
|
||||
// This is needed for the Instance<typeof Message> type
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fetch globally
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock the ClientChatStore
|
||||
@@ -31,14 +31,14 @@ vi.mock('../../../../stores/ClientChatStore', () => {
|
||||
removeAfter: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
editMessage: vi.fn().mockReturnValue(true)
|
||||
editMessage: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
// Add the mockUserMessage to the items array
|
||||
mockStore.items.indexOf = vi.fn().mockReturnValue(0);
|
||||
|
||||
return {
|
||||
default: mockStore
|
||||
default: mockStore,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,25 +48,25 @@ vi.mock('../../../../stores/MessageEditorStore', () => {
|
||||
editedContent: 'Test message', // Set initial value to match the test expectation
|
||||
message: null,
|
||||
setEditedContent: vi.fn(),
|
||||
setMessage: vi.fn((message) => {
|
||||
setMessage: vi.fn(message => {
|
||||
mockStore.message = message;
|
||||
mockStore.editedContent = message.content;
|
||||
}),
|
||||
onCancel: vi.fn(),
|
||||
handleSave: vi.fn()
|
||||
handleSave: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
default: mockStore
|
||||
default: mockStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe('MessageEditor', () => {
|
||||
// Create a message object with a setContent method
|
||||
const mockUserMessage = {
|
||||
content: 'Test message',
|
||||
const mockUserMessage = {
|
||||
content: 'Test message',
|
||||
role: 'user',
|
||||
setContent: vi.fn()
|
||||
setContent: vi.fn(),
|
||||
};
|
||||
const mockOnCancel = vi.fn();
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('MessageEditor', () => {
|
||||
});
|
||||
|
||||
it('should call handleSave when save button is clicked', () => {
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel}/>);
|
||||
render(<MessageEditor message={mockUserMessage} onCancel={mockOnCancel} />);
|
||||
|
||||
const saveButton = screen.getByLabelText('Save edit');
|
||||
fireEvent.click(saveButton);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { buildCodeHighlighter } from "./CodeHighlighter";
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { buildCodeHighlighter } from './CodeHighlighter';
|
||||
|
||||
interface CodeBlockProps {
|
||||
language: string;
|
||||
@@ -9,23 +10,19 @@ interface CodeBlockProps {
|
||||
|
||||
const highlighter = buildCodeHighlighter();
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
language,
|
||||
code,
|
||||
onRenderComplete,
|
||||
}) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ language, code, onRenderComplete }) => {
|
||||
const [html, setHtml] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
try {
|
||||
const highlighted = (await highlighter).codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "github-dark",
|
||||
theme: 'github-dark',
|
||||
});
|
||||
setHtml(highlighted);
|
||||
} catch (error) {
|
||||
console.error("Error highlighting code:", error);
|
||||
console.error('Error highlighting code:', error);
|
||||
setHtml(`<pre>${code}</pre>`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -41,9 +38,9 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#24292e",
|
||||
padding: "10px",
|
||||
borderRadius: "1.5em",
|
||||
backgroundColor: '#24292e',
|
||||
padding: '10px',
|
||||
borderRadius: '1.5em',
|
||||
}}
|
||||
>
|
||||
Loading code...
|
||||
@@ -55,12 +52,12 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{
|
||||
transition: "none",
|
||||
transition: 'none',
|
||||
padding: 20,
|
||||
backgroundColor: "#24292e",
|
||||
overflowX: "auto",
|
||||
borderRadius: ".37em",
|
||||
fontSize: ".75rem",
|
||||
backgroundColor: '#24292e',
|
||||
overflowX: 'auto',
|
||||
borderRadius: '.37em',
|
||||
fontSize: '.75rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { createHighlighterCore } from "shiki";
|
||||
import { createHighlighterCore } from 'shiki';
|
||||
|
||||
/* eslint-disable import/no-unresolved */
|
||||
export async function buildCodeHighlighter() {
|
||||
const [
|
||||
githubDark,
|
||||
@@ -23,26 +24,26 @@ export async function buildCodeHighlighter() {
|
||||
zig,
|
||||
wasm,
|
||||
] = await Promise.all([
|
||||
import("shiki/themes/github-dark.mjs"),
|
||||
import("shiki/langs/html.mjs"),
|
||||
import("shiki/langs/javascript.mjs"),
|
||||
import("shiki/langs/jsx.mjs"),
|
||||
import("shiki/langs/typescript.mjs"),
|
||||
import("shiki/langs/tsx.mjs"),
|
||||
import("shiki/langs/go.mjs"),
|
||||
import("shiki/langs/rust.mjs"),
|
||||
import("shiki/langs/python.mjs"),
|
||||
import("shiki/langs/java.mjs"),
|
||||
import("shiki/langs/kotlin.mjs"),
|
||||
import("shiki/langs/shell.mjs"),
|
||||
import("shiki/langs/sql.mjs"),
|
||||
import("shiki/langs/yaml.mjs"),
|
||||
import("shiki/langs/toml.mjs"),
|
||||
import("shiki/langs/markdown.mjs"),
|
||||
import("shiki/langs/json.mjs"),
|
||||
import("shiki/langs/xml.mjs"),
|
||||
import("shiki/langs/zig.mjs"),
|
||||
import("shiki/wasm"),
|
||||
import('shiki/themes/github-dark.mjs'),
|
||||
import('shiki/langs/html.mjs'),
|
||||
import('shiki/langs/javascript.mjs'),
|
||||
import('shiki/langs/jsx.mjs'),
|
||||
import('shiki/langs/typescript.mjs'),
|
||||
import('shiki/langs/tsx.mjs'),
|
||||
import('shiki/langs/go.mjs'),
|
||||
import('shiki/langs/rust.mjs'),
|
||||
import('shiki/langs/python.mjs'),
|
||||
import('shiki/langs/java.mjs'),
|
||||
import('shiki/langs/kotlin.mjs'),
|
||||
import('shiki/langs/shell.mjs'),
|
||||
import('shiki/langs/sql.mjs'),
|
||||
import('shiki/langs/yaml.mjs'),
|
||||
import('shiki/langs/toml.mjs'),
|
||||
import('shiki/langs/markdown.mjs'),
|
||||
import('shiki/langs/json.mjs'),
|
||||
import('shiki/langs/xml.mjs'),
|
||||
import('shiki/langs/zig.mjs'),
|
||||
import('shiki/wasm'),
|
||||
]);
|
||||
|
||||
// Create the highlighter instance with the loaded themes and languages
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
@@ -9,40 +8,41 @@ import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
} from "@chakra-ui/react";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { Fragment, useState } from "react";
|
||||
} from '@chakra-ui/react';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
|
||||
function ConnectComponent() {
|
||||
const [formData, setFormData] = useState({
|
||||
markdown: "",
|
||||
email: "",
|
||||
firstname: "",
|
||||
lastname: "",
|
||||
markdown: '',
|
||||
email: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
});
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [validationError, setValidationError] = useState("");
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleChange = (field: string) => (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setIsSubmitted(false);
|
||||
setValidationError("");
|
||||
setValidationError('');
|
||||
};
|
||||
|
||||
const handleSubmitButton = async () => {
|
||||
setValidationError("");
|
||||
setValidationError('');
|
||||
|
||||
if (!formData.email || !formData.firstname || !formData.markdown) {
|
||||
setValidationError("Please fill in all required fields.");
|
||||
setValidationError('Please fill in all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
@@ -51,10 +51,10 @@ function ConnectComponent() {
|
||||
setIsSubmitted(true);
|
||||
setIsError(false);
|
||||
setFormData({
|
||||
markdown: "",
|
||||
email: "",
|
||||
firstname: "",
|
||||
lastname: "",
|
||||
markdown: '',
|
||||
email: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
});
|
||||
} else {
|
||||
setIsError(true);
|
||||
@@ -68,7 +68,7 @@ function ConnectComponent() {
|
||||
<Fragment>
|
||||
<List color="text.primary" mb={4}>
|
||||
<ListItem>
|
||||
Email:{" "}
|
||||
Email:{' '}
|
||||
<Link href="mailto:geoff@seemueller.io" color="teal.500">
|
||||
geoff@seemueller.io
|
||||
</Link>
|
||||
@@ -79,14 +79,14 @@ function ConnectComponent() {
|
||||
<Input
|
||||
placeholder="First name *"
|
||||
value={formData.firstname}
|
||||
onChange={(e) => handleChange("firstname")(e.target.value)}
|
||||
onChange={e => handleChange('firstname')(e.target.value)}
|
||||
color="text.primary"
|
||||
borderColor="text.primary"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Last name *"
|
||||
value={formData.lastname}
|
||||
onChange={(e) => handleChange("lastname")(e.target.value)}
|
||||
onChange={e => handleChange('lastname')(e.target.value)}
|
||||
color="text.primary"
|
||||
borderColor="text.primary"
|
||||
// bg="text.primary"
|
||||
@@ -95,13 +95,13 @@ function ConnectComponent() {
|
||||
<Input
|
||||
placeholder="Email *"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email")(e.target.value)}
|
||||
onChange={e => handleChange('email')(e.target.value)}
|
||||
mb={4}
|
||||
borderColor="text.primary"
|
||||
color="text.primary"
|
||||
/>
|
||||
<MarkdownEditor
|
||||
onChange={handleChange("markdown")}
|
||||
onChange={handleChange('markdown')}
|
||||
markdown={formData.markdown}
|
||||
placeholder="Your Message..."
|
||||
/>
|
||||
@@ -116,47 +116,32 @@ function ConnectComponent() {
|
||||
mb={4}
|
||||
float="right"
|
||||
_hover={{
|
||||
bg: "",
|
||||
transform: "scale(1.05)",
|
||||
bg: '',
|
||||
transform: 'scale(1.05)',
|
||||
}}
|
||||
_active={{
|
||||
bg: "gray.800",
|
||||
transform: "scale(1)",
|
||||
bg: 'gray.800',
|
||||
transform: 'scale(1)',
|
||||
}}
|
||||
>
|
||||
SEND
|
||||
</Button>
|
||||
<Box mt={12}>
|
||||
{isSubmitted && (
|
||||
<Alert
|
||||
status="success"
|
||||
borderRadius="md"
|
||||
color="text.primary"
|
||||
bg="green.500"
|
||||
>
|
||||
<Alert status="success" borderRadius="md" color="text.primary" bg="green.500">
|
||||
<AlertIcon />
|
||||
Message sent successfully!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert
|
||||
status="error"
|
||||
borderRadius="md"
|
||||
color="text.primary"
|
||||
bg="red.500"
|
||||
>
|
||||
<Alert status="error" borderRadius="md" color="text.primary" bg="red.500">
|
||||
<AlertIcon />
|
||||
There was an error sending your message. Please try again.
|
||||
</Alert>
|
||||
)}
|
||||
{validationError && (
|
||||
<Alert
|
||||
status="warning"
|
||||
borderRadius="md"
|
||||
color="background.primary"
|
||||
bg="yellow.500"
|
||||
>
|
||||
<Alert status="warning" borderRadius="md" color="background.primary" bg="yellow.500">
|
||||
<AlertIcon />
|
||||
{validationError}
|
||||
</Alert>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Box, Textarea } from "@chakra-ui/react";
|
||||
import { Box, Textarea } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export const MarkdownEditor = (props: {
|
||||
placeholder: string;
|
||||
@@ -11,7 +11,7 @@ export const MarkdownEditor = (props: {
|
||||
<Textarea
|
||||
value={props.markdown}
|
||||
placeholder={props.placeholder}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
width="100%"
|
||||
minHeight="150px"
|
||||
height="100%"
|
||||
|
@@ -1,13 +1,9 @@
|
||||
import {
|
||||
ChakraProvider,
|
||||
cookieStorageManagerSSR,
|
||||
localStorageManager,
|
||||
} from "@chakra-ui/react";
|
||||
import { ChakraProvider, cookieStorageManagerSSR, localStorageManager } from '@chakra-ui/react';
|
||||
|
||||
export function Chakra({ cookies, children, theme }) {
|
||||
const colorModeManager =
|
||||
typeof cookies === "string"
|
||||
? cookieStorageManagerSSR("color_state", cookies)
|
||||
typeof cookies === 'string'
|
||||
? cookieStorageManagerSSR('color_state', cookies)
|
||||
: localStorageManager;
|
||||
|
||||
return (
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "@chakra-ui/react";
|
||||
import { useMediaQuery } from '@chakra-ui/react';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
// Create the context to provide mobile state
|
||||
const MobileContext = createContext(false);
|
||||
@@ -7,25 +7,20 @@ const MobileContext = createContext(false);
|
||||
// Create a provider component to wrap your app
|
||||
export const MobileProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isFallbackMobile] = useMediaQuery("(max-width: 768px)");
|
||||
const [isFallbackMobile] = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
const mobile =
|
||||
/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
userAgent.toLowerCase(),
|
||||
);
|
||||
const mobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
userAgent.toLowerCase(),
|
||||
);
|
||||
setIsMobile(mobile);
|
||||
}, []);
|
||||
|
||||
// Provide the combined mobile state globally
|
||||
const mobileState = isMobile || isFallbackMobile;
|
||||
|
||||
return (
|
||||
<MobileContext.Provider value={mobileState}>
|
||||
{children}
|
||||
</MobileContext.Provider>
|
||||
);
|
||||
return <MobileContext.Provider value={mobileState}>{children}</MobileContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the mobile context in any component
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Badge, Box, Flex, Heading, Image, Text } from "@chakra-ui/react";
|
||||
import { Badge, Box, Flex, Heading, Image, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
function DemoCard({ icon, title, description, imageUrl, badge, onClick }) {
|
||||
return (
|
||||
@@ -9,15 +9,15 @@ function DemoCard({ icon, title, description, imageUrl, badge, onClick }) {
|
||||
overflowY="hidden"
|
||||
boxShadow="md"
|
||||
transition="transform 0.2s"
|
||||
_hover={{ transform: "scale(1.05)", cursor: "pointer" }}
|
||||
_hover={{ transform: 'scale(1.05)', cursor: 'pointer' }}
|
||||
color="text.primary"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
minW={"12rem"}
|
||||
maxW={"18rem"}
|
||||
minH={"35rem"}
|
||||
maxH={"20rem"}
|
||||
minW={'12rem'}
|
||||
maxW={'18rem'}
|
||||
minH={'35rem'}
|
||||
maxH={'20rem'}
|
||||
>
|
||||
{imageUrl && (
|
||||
<Image
|
||||
@@ -42,7 +42,7 @@ function DemoCard({ icon, title, description, imageUrl, badge, onClick }) {
|
||||
</Flex>
|
||||
{badge && (
|
||||
<Box p={2}>
|
||||
<Badge colorScheme={"teal"}>{badge}</Badge>
|
||||
<Badge colorScheme={'teal'}>{badge}</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
@@ -1,16 +1,12 @@
|
||||
import React from "react";
|
||||
import { SimpleGrid } from "@chakra-ui/react";
|
||||
import { Rocket, Shield } from "lucide-react";
|
||||
import DemoCard from "./DemoCard";
|
||||
import { SimpleGrid } from '@chakra-ui/react';
|
||||
import { Rocket, Shield } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import DemoCard from './DemoCard';
|
||||
|
||||
function DemoComponent() {
|
||||
return (
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, sm: 1, lg: 2 }}
|
||||
spacing={"7%"}
|
||||
minH={"min-content"}
|
||||
h={"100vh"}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, sm: 1, lg: 2 }} spacing={'7%'} minH={'min-content'} h={'100vh'}>
|
||||
<DemoCard
|
||||
icon={<Rocket size={24} color="teal" />}
|
||||
title="toak"
|
||||
@@ -18,7 +14,7 @@ function DemoComponent() {
|
||||
imageUrl="/code-tokenizer-md.jpg"
|
||||
badge="npm"
|
||||
onClick={() => {
|
||||
window.open("https://github.com/seemueller-io/toak");
|
||||
window.open('https://github.com/seemueller-io/toak');
|
||||
}}
|
||||
/>
|
||||
<DemoCard
|
||||
@@ -28,7 +24,7 @@ function DemoComponent() {
|
||||
imageUrl="/rehoboam.png"
|
||||
badge="APP"
|
||||
onClick={() => {
|
||||
window.open("https://rehoboam.seemueller.io");
|
||||
window.open('https://rehoboam.seemueller.io');
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -14,9 +13,11 @@ import {
|
||||
Textarea,
|
||||
useToast,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import feedbackState from "../../stores/ClientFeedbackStore";
|
||||
} from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import feedbackState from '../../stores/ClientFeedbackStore';
|
||||
|
||||
const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
const toast = useToast();
|
||||
@@ -26,9 +27,9 @@ const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: "Feedback Submitted",
|
||||
description: "Thank you for your feedback!",
|
||||
status: "success",
|
||||
title: 'Feedback Submitted',
|
||||
description: 'Thank you for your feedback!',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
@@ -40,9 +41,9 @@ const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Submission Failed",
|
||||
title: 'Submission Failed',
|
||||
description: feedbackState.error,
|
||||
status: "error",
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
@@ -78,7 +79,7 @@ const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
<Textarea
|
||||
placeholder="Type your feedback here..."
|
||||
value={feedbackState.input}
|
||||
onChange={(e) => feedbackState.setInput(e.target.value)}
|
||||
onChange={e => feedbackState.setInput(e.target.value)}
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
minHeight="120px"
|
||||
@@ -89,7 +90,7 @@ const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
bottom="2"
|
||||
right="2"
|
||||
fontSize="xs"
|
||||
color={charactersRemaining < 50 ? "orange.300" : "gray.400"}
|
||||
color={charactersRemaining < 50 ? 'orange.300' : 'gray.400'}
|
||||
>
|
||||
{charactersRemaining} characters remaining
|
||||
</Text>
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const TealDogecoinIcon = (props) => (
|
||||
const TealDogecoinIcon = props => (
|
||||
<Box
|
||||
as="svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke={"currentColor"}
|
||||
stroke={'currentColor'}
|
||||
fill="currentColor"
|
||||
boxSize={props.boxSize || "1em"}
|
||||
boxSize={props.boxSize || '1em'}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
|
@@ -1,18 +1,14 @@
|
||||
import React from "react";
|
||||
import { Box, VStack } from "@chakra-ui/react";
|
||||
import {renderMarkdown} from "../markdown/MarkdownComponent";
|
||||
import { Box, VStack } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { renderMarkdown } from '../markdown/MarkdownComponent';
|
||||
|
||||
function LegalDoc({ text }) {
|
||||
return (
|
||||
<Box maxWidth="800px" margin="0 auto">
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box
|
||||
color="text.primary"
|
||||
wordBreak="break-word"
|
||||
whiteSpace="pre-wrap"
|
||||
spacing={4}
|
||||
>
|
||||
{renderMarkdown(text)}
|
||||
<Box color="text.primary" wordBreak="break-word" whiteSpace="pre-wrap" spacing={4}>
|
||||
{renderMarkdown(text)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
@@ -1,21 +1,16 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Image, Box, Spinner, Text, Flex } from "@chakra-ui/react";
|
||||
import { keyframes } from "@emotion/react";
|
||||
import { Image, Box, Spinner, Text, Flex } from '@chakra-ui/react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -100% 0; }
|
||||
100% { background-position: 100% 0; }
|
||||
`;
|
||||
|
||||
const ImageWithFallback = ({
|
||||
alt,
|
||||
src,
|
||||
fallbackSrc = "/fallback.png",
|
||||
...props
|
||||
}) => {
|
||||
const ImageWithFallback = ({ alt, src, fallbackSrc = '/fallback.png', ...props }) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const isSlowLoadingSource = src.includes("text2image.seemueller.io");
|
||||
const isSlowLoadingSource = src.includes('text2image.seemueller.io');
|
||||
|
||||
const handleImageLoad = () => setIsLoading(false);
|
||||
const handleImageError = () => {
|
||||
@@ -33,24 +28,17 @@ const ImageWithFallback = ({
|
||||
setScrollPosition(scrolled);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const parallaxOffset = scrollPosition * 0.2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
w="full"
|
||||
maxW="full"
|
||||
borderRadius="md"
|
||||
my={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box position="relative" w="full" maxW="full" borderRadius="md" my={2} overflow="hidden">
|
||||
{isLoading && isSlowLoadingSource && (
|
||||
<Flex
|
||||
align="center"
|
||||
@@ -76,7 +64,7 @@ const ImageWithFallback = ({
|
||||
fallbackSrc={fallbackSrc}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
display={isLoading ? "none" : "block"}
|
||||
display={isLoading ? 'none' : 'block'}
|
||||
transform={`translateY(${parallaxOffset}px)`}
|
||||
transition="transform 0.1s ease-out"
|
||||
{...props}
|
||||
|
@@ -1,576 +1,487 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
Divider,
|
||||
Heading,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import {marked} from "marked";
|
||||
Box,
|
||||
Code,
|
||||
Divider,
|
||||
Heading,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import katex from 'katex';
|
||||
import { marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import React from 'react';
|
||||
|
||||
import markedKatex from "marked-katex-extension";
|
||||
import katex from "katex";
|
||||
import CodeBlock from "../code/CodeBlock";
|
||||
import ImageWithFallback from "./ImageWithFallback";
|
||||
import CodeBlock from '../code/CodeBlock';
|
||||
|
||||
import ImageWithFallback from './ImageWithFallback';
|
||||
|
||||
try {
|
||||
if (localStorage) {
|
||||
marked.use(
|
||||
markedKatex({
|
||||
nonStandard: false,
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
strict: true,
|
||||
colorIsTextColor: true,
|
||||
errorColor: "red",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (localStorage) {
|
||||
marked.use(
|
||||
markedKatex({
|
||||
nonStandard: false,
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
strict: true,
|
||||
colorIsTextColor: true,
|
||||
errorColor: 'red',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently ignore errors in marked setup - fallback to default behavior
|
||||
}
|
||||
|
||||
const MemoizedCodeBlock = React.memo(CodeBlock);
|
||||
|
||||
const getHeadingProps = (depth: number) => {
|
||||
switch (depth) {
|
||||
case 1:
|
||||
return {as: "h1", size: "xl", mt: 4, mb: 2};
|
||||
case 2:
|
||||
return {as: "h2", size: "lg", mt: 3, mb: 2};
|
||||
case 3:
|
||||
return {as: "h3", size: "md", mt: 2, mb: 1};
|
||||
case 4:
|
||||
return {as: "h4", size: "sm", mt: 2, mb: 1};
|
||||
case 5:
|
||||
return {as: "h5", size: "sm", mt: 2, mb: 1};
|
||||
case 6:
|
||||
return {as: "h6", size: "xs", mt: 2, mb: 1};
|
||||
default:
|
||||
return {as: `h${depth}`, size: "md", mt: 2, mb: 1};
|
||||
}
|
||||
switch (depth) {
|
||||
case 1:
|
||||
return { as: 'h1', size: 'xl', mt: 4, mb: 2 };
|
||||
case 2:
|
||||
return { as: 'h2', size: 'lg', mt: 3, mb: 2 };
|
||||
case 3:
|
||||
return { as: 'h3', size: 'md', mt: 2, mb: 1 };
|
||||
case 4:
|
||||
return { as: 'h4', size: 'sm', mt: 2, mb: 1 };
|
||||
case 5:
|
||||
return { as: 'h5', size: 'sm', mt: 2, mb: 1 };
|
||||
case 6:
|
||||
return { as: 'h6', size: 'xs', mt: 2, mb: 1 };
|
||||
default:
|
||||
return { as: `h${depth}`, size: 'md', mt: 2, mb: 1 };
|
||||
}
|
||||
};
|
||||
|
||||
interface TableToken extends marked.Tokens.Table {
|
||||
align: Array<"center" | "left" | "right" | null>;
|
||||
header: (string | marked.Tokens.TableCell)[];
|
||||
rows: (string | marked.Tokens.TableCell)[][];
|
||||
align: Array<'center' | 'left' | 'right' | null>;
|
||||
header: (string | marked.Tokens.TableCell)[];
|
||||
rows: (string | marked.Tokens.TableCell)[][];
|
||||
}
|
||||
|
||||
const CustomHeading: React.FC<{ text: string; depth: number }> = ({
|
||||
text,
|
||||
depth,
|
||||
}) => {
|
||||
const headingProps = getHeadingProps(depth);
|
||||
return (
|
||||
<Heading
|
||||
{...headingProps}
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
color="text.accent"
|
||||
>
|
||||
{text}
|
||||
</Heading>
|
||||
);
|
||||
const CustomHeading: React.FC<{ text: string; depth: number }> = ({ text, depth }) => {
|
||||
const headingProps = getHeadingProps(depth);
|
||||
return (
|
||||
<Heading {...headingProps} wordBreak="break-word" maxWidth="100%" color="text.accent">
|
||||
{text}
|
||||
</Heading>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Text
|
||||
as="p"
|
||||
fontSize="sm"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Text as="p" fontSize="sm" lineHeight="short" wordBreak="break-word" maxWidth="100%">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomBlockquote: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
as="blockquote"
|
||||
borderLeft="4px solid"
|
||||
borderColor="gray.200"
|
||||
fontStyle="italic"
|
||||
color="gray.600"
|
||||
pl={4}
|
||||
maxWidth="100%"
|
||||
wordBreak="break-word"
|
||||
mb={2}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
const CustomBlockquote: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Box
|
||||
as="blockquote"
|
||||
borderLeft="4px solid"
|
||||
borderColor="gray.200"
|
||||
fontStyle="italic"
|
||||
color="gray.600"
|
||||
pl={4}
|
||||
maxWidth="100%"
|
||||
wordBreak="break-word"
|
||||
mb={2}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomCodeBlock: React.FC<{ code: string; language?: string }> = ({
|
||||
code,
|
||||
language,
|
||||
}) => {
|
||||
return (
|
||||
<MemoizedCodeBlock
|
||||
language={language}
|
||||
code={code}
|
||||
onRenderComplete={() => Promise.resolve()}
|
||||
/>
|
||||
);
|
||||
const CustomCodeBlock: React.FC<{ code: string; language?: string }> = ({ code, language }) => {
|
||||
return (
|
||||
<MemoizedCodeBlock language={language} code={code} onRenderComplete={() => Promise.resolve()} />
|
||||
);
|
||||
};
|
||||
|
||||
const CustomHr: React.FC = () => <Divider my={4}/>;
|
||||
const CustomHr: React.FC = () => <Divider my={4} />;
|
||||
|
||||
const CustomList: React.FC<{
|
||||
ordered?: boolean;
|
||||
start?: number;
|
||||
children: React.ReactNode;
|
||||
}> = ({ordered, start, children}) => {
|
||||
const commonStyles = {
|
||||
fontSize: "sm",
|
||||
wordBreak: "break-word" as const,
|
||||
maxWidth: "100%" as const,
|
||||
stylePosition: "outside" as const,
|
||||
mb: 2,
|
||||
pl: 4,
|
||||
};
|
||||
ordered?: boolean;
|
||||
start?: number;
|
||||
children: React.ReactNode;
|
||||
}> = ({ ordered, start, children }) => {
|
||||
const commonStyles = {
|
||||
fontSize: 'sm',
|
||||
wordBreak: 'break-word' as const,
|
||||
maxWidth: '100%' as const,
|
||||
stylePosition: 'outside' as const,
|
||||
mb: 2,
|
||||
pl: 4,
|
||||
};
|
||||
|
||||
return ordered ? (
|
||||
<OrderedList start={start} {...commonStyles}>
|
||||
{children}
|
||||
</OrderedList>
|
||||
) : (
|
||||
<List styleType="disc" {...commonStyles}>
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
return ordered ? (
|
||||
<OrderedList start={start} {...commonStyles}>
|
||||
{children}
|
||||
</OrderedList>
|
||||
) : (
|
||||
<List styleType="disc" {...commonStyles}>
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomListItem: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({children}) => {
|
||||
return <ListItem mb={1}>{children}</ListItem>;
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return <ListItem mb={1}>{children}</ListItem>;
|
||||
};
|
||||
|
||||
const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({
|
||||
math,
|
||||
displayMode,
|
||||
}) => {
|
||||
const renderedMath = katex.renderToString(math, {displayMode});
|
||||
const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({ math, displayMode }) => {
|
||||
const renderedMath = katex.renderToString(math, { displayMode });
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? "block" : "inline"}
|
||||
// bg={bg}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
overflow="auto"
|
||||
maxWidth="100%"
|
||||
dangerouslySetInnerHTML={{__html: renderedMath}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? 'block' : 'inline'}
|
||||
// bg={bg}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
overflow="auto"
|
||||
maxWidth="100%"
|
||||
dangerouslySetInnerHTML={{ __html: renderedMath }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTable: React.FC<{
|
||||
header: React.ReactNode[];
|
||||
align: Array<"center" | "left" | "right" | null>;
|
||||
rows: React.ReactNode[][];
|
||||
}> = ({header, align, rows}) => {
|
||||
return (
|
||||
<Table
|
||||
variant="simple"
|
||||
size="sm"
|
||||
my={4}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Thead bg="background.secondary">
|
||||
<Tr>
|
||||
{header.map((cell, i) => (
|
||||
<Th
|
||||
key={i}
|
||||
textAlign={align[i] || "left"}
|
||||
fontWeight="bold"
|
||||
p={2}
|
||||
minW={16}
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{cell}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rows.map((row, rIndex) => (
|
||||
<Tr key={rIndex}>
|
||||
{row.map((cell, cIndex) => (
|
||||
<Td
|
||||
key={cIndex}
|
||||
textAlign={align[cIndex] || "left"}
|
||||
p={2}
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{cell}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
header: React.ReactNode[];
|
||||
align: Array<'center' | 'left' | 'right' | null>;
|
||||
rows: React.ReactNode[][];
|
||||
}> = ({ header, align, rows }) => {
|
||||
return (
|
||||
<Table variant="simple" size="sm" my={4} borderRadius="md" overflow="hidden">
|
||||
<Thead bg="background.secondary">
|
||||
<Tr>
|
||||
{header.map((cell, i) => (
|
||||
<Th
|
||||
key={i}
|
||||
textAlign={align[i] || 'left'}
|
||||
fontWeight="bold"
|
||||
p={2}
|
||||
minW={16}
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{cell}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rows.map((row, rIndex) => (
|
||||
<Tr key={rIndex}>
|
||||
{row.map((cell, cIndex) => (
|
||||
<Td key={cIndex} textAlign={align[cIndex] || 'left'} p={2} wordBreak="break-word">
|
||||
{cell}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomHtmlBlock: React.FC<{ content: string }> = ({content}) => {
|
||||
return <Box as="span" display="inline" dangerouslySetInnerHTML={{__html: content}} mb={2}/>;
|
||||
const CustomHtmlBlock: React.FC<{ content: string }> = ({ content }) => {
|
||||
return <Box as="span" display="inline" dangerouslySetInnerHTML={{ __html: content }} mb={2} />;
|
||||
};
|
||||
|
||||
const CustomText: React.FC<{ text: React.ReactNode }> = ({text}) => {
|
||||
return (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
lineHeight="short"
|
||||
color="text.accent"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
as="span"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
const CustomText: React.FC<{ text: React.ReactNode }> = ({ text }) => {
|
||||
return (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
lineHeight="short"
|
||||
color="text.accent"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
as="span"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomStrongProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomStrong: React.FC<CustomStrongProps> = ({children}) => {
|
||||
return <Text as="strong">{children}</Text>;
|
||||
const CustomStrong: React.FC<CustomStrongProps> = ({ children }) => {
|
||||
return <Text as="strong">{children}</Text>;
|
||||
};
|
||||
|
||||
const CustomEm: React.FC<{ children: React.ReactNode }> = ({children}) => {
|
||||
return (
|
||||
<Text
|
||||
as="em"
|
||||
fontStyle="italic"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
display="inline"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
const CustomEm: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Text as="em" fontStyle="italic" lineHeight="short" wordBreak="break-word" display="inline">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomDel: React.FC<{ text: string }> = ({text}) => {
|
||||
return (
|
||||
<Text
|
||||
as="del"
|
||||
textDecoration="line-through"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
display="inline"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
const CustomDel: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<Text
|
||||
as="del"
|
||||
textDecoration="line-through"
|
||||
lineHeight="short"
|
||||
wordBreak="break-word"
|
||||
display="inline"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomCodeSpan: React.FC<{ code: string }> = ({code}) => {
|
||||
const bg = useColorModeValue("gray.100", "gray.800");
|
||||
return (
|
||||
<Code
|
||||
fontSize="sm"
|
||||
bg={bg}
|
||||
overflowX="clip"
|
||||
borderRadius="md"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
p={0.5}
|
||||
>
|
||||
{code}
|
||||
</Code>
|
||||
);
|
||||
const CustomCodeSpan: React.FC<{ code: string }> = ({ code }) => {
|
||||
const bg = useColorModeValue('gray.100', 'gray.800');
|
||||
return (
|
||||
<Code
|
||||
fontSize="sm"
|
||||
bg={bg}
|
||||
overflowX="clip"
|
||||
borderRadius="md"
|
||||
wordBreak="break-word"
|
||||
maxWidth="100%"
|
||||
p={0.5}
|
||||
>
|
||||
{code}
|
||||
</Code>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomMath: React.FC<{ math: string; displayMode?: boolean }> = ({
|
||||
math,
|
||||
displayMode = false,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? "block" : "inline"}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
overflow="auto"
|
||||
maxWidth="100%"
|
||||
className={`math ${displayMode ? "math-display" : "math-inline"}`}
|
||||
>
|
||||
{math}
|
||||
</Box>
|
||||
);
|
||||
math,
|
||||
displayMode = false,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display={displayMode ? 'block' : 'inline'}
|
||||
p={displayMode ? 4 : 1}
|
||||
my={displayMode ? 4 : 0}
|
||||
borderRadius="md"
|
||||
overflow="auto"
|
||||
maxWidth="100%"
|
||||
className={`math ${displayMode ? 'math-display' : 'math-inline'}`}
|
||||
>
|
||||
{math}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLink: React.FC<{
|
||||
href: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({href, title, children, ...props}) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
isExternal
|
||||
sx={{
|
||||
"& span": {
|
||||
color: "text.link",
|
||||
},
|
||||
}}
|
||||
maxWidth="100%"
|
||||
color="teal.500"
|
||||
wordBreak="break-word"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
href: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ href, title, children, ...props }) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
isExternal
|
||||
sx={{
|
||||
'& span': {
|
||||
color: 'text.link',
|
||||
},
|
||||
}}
|
||||
maxWidth="100%"
|
||||
color="teal.500"
|
||||
wordBreak="break-word"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomImage: React.FC<{ href: string; text: string; title?: string }> = ({
|
||||
href,
|
||||
text,
|
||||
title,
|
||||
}) => {
|
||||
return (
|
||||
<ImageWithFallback
|
||||
src={href}
|
||||
alt={text}
|
||||
title={title}
|
||||
maxW="100%"
|
||||
width="auto"
|
||||
height="auto"
|
||||
my={2}
|
||||
/>
|
||||
);
|
||||
href,
|
||||
text,
|
||||
title,
|
||||
}) => {
|
||||
return (
|
||||
<ImageWithFallback
|
||||
src={href}
|
||||
alt={text}
|
||||
title={title}
|
||||
maxW="100%"
|
||||
width="auto"
|
||||
height="auto"
|
||||
my={2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function parseTokens(tokens: marked.Token[]): JSX.Element[] {
|
||||
const output: JSX.Element[] = [];
|
||||
let blockquoteContent: JSX.Element[] = [];
|
||||
const output: JSX.Element[] = [];
|
||||
let blockquoteContent: JSX.Element[] = [];
|
||||
|
||||
tokens.forEach((token, i) => {
|
||||
switch (token.type) {
|
||||
case "heading":
|
||||
output.push(
|
||||
<CustomHeading key={i} text={token.text} depth={token.depth}/>,
|
||||
);
|
||||
break;
|
||||
tokens.forEach((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'heading':
|
||||
output.push(<CustomHeading key={i} text={token.text} depth={token.depth} />);
|
||||
break;
|
||||
|
||||
case "paragraph": {
|
||||
const parsedContent = token.tokens
|
||||
? parseTokens(token.tokens)
|
||||
: token.text;
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(
|
||||
<CustomParagraph key={i}>{parsedContent}</CustomParagraph>,
|
||||
);
|
||||
} else {
|
||||
output.push(
|
||||
<CustomParagraph key={i}>{parsedContent}</CustomParagraph>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "br":
|
||||
output.push(<br key={i}/>);
|
||||
break;
|
||||
case "escape": {
|
||||
break;
|
||||
}
|
||||
case "blockquote_start":
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
|
||||
case "blockquote_end":
|
||||
output.push(
|
||||
<CustomBlockquote key={i}>
|
||||
{parseTokens(blockquoteContent)}
|
||||
</CustomBlockquote>,
|
||||
);
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
case "blockquote": {
|
||||
output.push(
|
||||
<CustomBlockquote key={i}>
|
||||
{token.tokens ? parseTokens(token.tokens) : null}
|
||||
</CustomBlockquote>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "math":
|
||||
output.push(
|
||||
<CustomMath key={i} math={(token as any).value} displayMode={true}/>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "inlineMath":
|
||||
output.push(
|
||||
<CustomMath
|
||||
key={i}
|
||||
math={(token as any).value}
|
||||
displayMode={false}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case "inlineKatex":
|
||||
case "blockKatex": {
|
||||
const katexToken = token as any;
|
||||
output.push(
|
||||
<CustomKatex
|
||||
key={i}
|
||||
math={katexToken.text}
|
||||
displayMode={katexToken.displayMode}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "code":
|
||||
output.push(
|
||||
<CustomCodeBlock key={i} code={token.text} language={token.lang}/>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "hr":
|
||||
output.push(<CustomHr key={i}/>);
|
||||
break;
|
||||
case "list": {
|
||||
const {ordered, start, items} = token;
|
||||
const listItems = items.map((listItem, idx) => {
|
||||
const nestedContent = parseTokens(listItem.tokens);
|
||||
return <CustomListItem key={idx}>{nestedContent}</CustomListItem>;
|
||||
});
|
||||
|
||||
output.push(
|
||||
<CustomList key={i} ordered={ordered} start={start}>
|
||||
{listItems}
|
||||
</CustomList>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "table": {
|
||||
const tableToken = token as TableToken;
|
||||
|
||||
output.push(
|
||||
<CustomTable
|
||||
key={i}
|
||||
header={tableToken.header.map((cell) =>
|
||||
typeof cell === "string" ? cell : parseTokens(cell.tokens || []),
|
||||
)}
|
||||
align={tableToken.align}
|
||||
rows={tableToken.rows.map((row) =>
|
||||
row.map((cell) =>
|
||||
typeof cell === "string"
|
||||
? cell
|
||||
: parseTokens(cell.tokens || []),
|
||||
),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "html":
|
||||
output.push(<CustomHtmlBlock key={i} content={token.text}/>);
|
||||
break;
|
||||
case "def":
|
||||
case "space":
|
||||
break;
|
||||
case "strong":
|
||||
output.push(
|
||||
<CustomStrong key={i}>
|
||||
{parseTokens(token.tokens || [])}
|
||||
</CustomStrong>,
|
||||
);
|
||||
break;
|
||||
case "em":
|
||||
output.push(
|
||||
<CustomEm key={i}>
|
||||
{token.tokens ? parseTokens(token.tokens) : token.text}
|
||||
</CustomEm>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "codespan":
|
||||
output.push(<CustomCodeSpan key={i} code={token.text}/>);
|
||||
break;
|
||||
|
||||
case "link":
|
||||
output.push(
|
||||
<CustomLink key={i} href={token.href} title={token.title}>
|
||||
{token.tokens ? parseTokens(token.tokens) : token.text}
|
||||
</CustomLink>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "image":
|
||||
output.push(
|
||||
<CustomImage
|
||||
key={i}
|
||||
href={token.href}
|
||||
title={token.title}
|
||||
text={token.text}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
|
||||
case "text": {
|
||||
const parsedContent = token.tokens
|
||||
? parseTokens(token.tokens)
|
||||
: token.text;
|
||||
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(
|
||||
<React.Fragment key={i}>{parsedContent}</React.Fragment>,
|
||||
);
|
||||
} else {
|
||||
output.push(<CustomText key={i} text={parsedContent}/>);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn("Unhandled token type:", token.type, token);
|
||||
case 'paragraph': {
|
||||
const parsedContent = token.tokens ? parseTokens(token.tokens) : token.text;
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(<CustomParagraph key={i}>{parsedContent}</CustomParagraph>);
|
||||
} else {
|
||||
output.push(<CustomParagraph key={i}>{parsedContent}</CustomParagraph>);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'br':
|
||||
output.push(<br key={i} />);
|
||||
break;
|
||||
case 'escape': {
|
||||
break;
|
||||
}
|
||||
case 'blockquote_start':
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
|
||||
return output;
|
||||
case 'blockquote_end':
|
||||
output.push(<CustomBlockquote key={i}>{parseTokens(blockquoteContent)}</CustomBlockquote>);
|
||||
blockquoteContent = [];
|
||||
break;
|
||||
case 'blockquote': {
|
||||
output.push(
|
||||
<CustomBlockquote key={i}>
|
||||
{token.tokens ? parseTokens(token.tokens) : null}
|
||||
</CustomBlockquote>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'math':
|
||||
output.push(<CustomMath key={i} math={(token as any).value} displayMode={true} />);
|
||||
break;
|
||||
|
||||
case 'inlineMath':
|
||||
output.push(<CustomMath key={i} math={(token as any).value} displayMode={false} />);
|
||||
break;
|
||||
case 'inlineKatex':
|
||||
case 'blockKatex': {
|
||||
const katexToken = token as any;
|
||||
output.push(
|
||||
<CustomKatex key={i} math={katexToken.text} displayMode={katexToken.displayMode} />,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'code':
|
||||
output.push(<CustomCodeBlock key={i} code={token.text} language={token.lang} />);
|
||||
break;
|
||||
|
||||
case 'hr':
|
||||
output.push(<CustomHr key={i} />);
|
||||
break;
|
||||
case 'list': {
|
||||
const { ordered, start, items } = token;
|
||||
const listItems = items.map((listItem, idx) => {
|
||||
const nestedContent = parseTokens(listItem.tokens);
|
||||
return <CustomListItem key={idx}>{nestedContent}</CustomListItem>;
|
||||
});
|
||||
|
||||
output.push(
|
||||
<CustomList key={i} ordered={ordered} start={start}>
|
||||
{listItems}
|
||||
</CustomList>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
const tableToken = token as TableToken;
|
||||
|
||||
output.push(
|
||||
<CustomTable
|
||||
key={i}
|
||||
header={tableToken.header.map(cell =>
|
||||
typeof cell === 'string' ? cell : parseTokens(cell.tokens || []),
|
||||
)}
|
||||
align={tableToken.align}
|
||||
rows={tableToken.rows.map(row =>
|
||||
row.map(cell => (typeof cell === 'string' ? cell : parseTokens(cell.tokens || []))),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'html':
|
||||
output.push(<CustomHtmlBlock key={i} content={token.text} />);
|
||||
break;
|
||||
case 'def':
|
||||
case 'space':
|
||||
break;
|
||||
case 'strong':
|
||||
output.push(<CustomStrong key={i}>{parseTokens(token.tokens || [])}</CustomStrong>);
|
||||
break;
|
||||
case 'em':
|
||||
output.push(
|
||||
<CustomEm key={i}>{token.tokens ? parseTokens(token.tokens) : token.text}</CustomEm>,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'codespan':
|
||||
output.push(<CustomCodeSpan key={i} code={token.text} />);
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
output.push(
|
||||
<CustomLink key={i} href={token.href} title={token.title}>
|
||||
{token.tokens ? parseTokens(token.tokens) : token.text}
|
||||
</CustomLink>,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
output.push(
|
||||
<CustomImage key={i} href={token.href} title={token.title} text={token.text} />,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'text': {
|
||||
const parsedContent = token.tokens ? parseTokens(token.tokens) : token.text;
|
||||
|
||||
if (blockquoteContent.length > 0) {
|
||||
blockquoteContent.push(<React.Fragment key={i}>{parsedContent}</React.Fragment>);
|
||||
} else {
|
||||
output.push(<CustomText key={i} text={parsedContent} />);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unhandled token type:', token.type, token);
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function renderMarkdown(markdown: string): JSX.Element[] {
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
silent: false,
|
||||
async: true,
|
||||
});
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
silent: false,
|
||||
async: true,
|
||||
});
|
||||
|
||||
const tokens = marked.lexer(markdown);
|
||||
return parseTokens(tokens);
|
||||
const tokens = marked.lexer(markdown);
|
||||
return parseTokens(tokens);
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "@chakra-ui/react";
|
||||
import { Github } from "lucide-react";
|
||||
import { toolbarButtonZIndex } from "./Toolbar";
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { Github } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { toolbarButtonZIndex } from './Toolbar';
|
||||
|
||||
export default function GithubButton() {
|
||||
return (
|
||||
@@ -16,10 +17,10 @@ export default function GithubButton() {
|
||||
stroke="text.accent"
|
||||
color="text.accent"
|
||||
_hover={{
|
||||
bg: "transparent",
|
||||
bg: 'transparent',
|
||||
svg: {
|
||||
stroke: "accent.secondary",
|
||||
transition: "stroke 0.3s ease-in-out",
|
||||
stroke: 'accent.secondary',
|
||||
transition: 'stroke 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
title="GitHub"
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { IconButton, useDisclosure } from "@chakra-ui/react";
|
||||
import { LucideHeart } from "lucide-react";
|
||||
import { toolbarButtonZIndex } from "./Toolbar";
|
||||
import SupportThisSiteModal from "./SupportThisSiteModal";
|
||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { LucideHeart } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import SupportThisSiteModal from './SupportThisSiteModal';
|
||||
import { toolbarButtonZIndex } from './Toolbar';
|
||||
|
||||
export default function SupportThisSiteButton() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -18,10 +19,10 @@ export default function SupportThisSiteButton() {
|
||||
stroke="text.accent"
|
||||
bg="transparent"
|
||||
_hover={{
|
||||
bg: "transparent",
|
||||
bg: 'transparent',
|
||||
svg: {
|
||||
stroke: "accent.danger",
|
||||
transition: "stroke 0.3s ease-in-out",
|
||||
stroke: 'accent.danger',
|
||||
transition: 'stroke 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
title="Support"
|
||||
@@ -29,9 +30,9 @@ export default function SupportThisSiteButton() {
|
||||
zIndex={toolbarButtonZIndex}
|
||||
sx={{
|
||||
svg: {
|
||||
stroke: "text.accent",
|
||||
strokeWidth: "2px",
|
||||
transition: "stroke 0.2s ease-in-out",
|
||||
stroke: 'text.accent',
|
||||
strokeWidth: '2px',
|
||||
transition: 'stroke 0.2s ease-in-out',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -19,26 +18,26 @@ import {
|
||||
useClipboard,
|
||||
useToast,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { FaBitcoin, FaEthereum } from "react-icons/fa";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import clientTransactionStore from "../../stores/ClientTransactionStore";
|
||||
import DogecoinIcon from "../icons/DogecoinIcon";
|
||||
} from '@chakra-ui/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import React from 'react';
|
||||
import { FaBitcoin, FaEthereum } from 'react-icons/fa';
|
||||
|
||||
import clientTransactionStore from '../../stores/ClientTransactionStore';
|
||||
import DogecoinIcon from '../icons/DogecoinIcon';
|
||||
|
||||
const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
const { hasCopied, onCopy } = useClipboard(
|
||||
clientTransactionStore.depositAddress || "",
|
||||
);
|
||||
const { hasCopied, onCopy } = useClipboard(clientTransactionStore.depositAddress || '');
|
||||
const toast = useToast();
|
||||
|
||||
const handleCopy = () => {
|
||||
if (clientTransactionStore.depositAddress) {
|
||||
onCopy();
|
||||
toast({
|
||||
title: "Address Copied!",
|
||||
description: "Thank you for your support!",
|
||||
status: "success",
|
||||
title: 'Address Copied!',
|
||||
description: 'Thank you for your support!',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
@@ -49,17 +48,17 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
try {
|
||||
await clientTransactionStore.prepareTransaction();
|
||||
toast({
|
||||
title: "Success",
|
||||
title: 'Success',
|
||||
description: `Use your wallet app (Coinbase, ...ect) to send the selected asset to the provided address.`,
|
||||
status: "success",
|
||||
status: 'success',
|
||||
duration: 6000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Transaction Failed",
|
||||
description: "There was an issue preparing your transaction.",
|
||||
status: "error",
|
||||
title: 'Transaction Failed',
|
||||
description: 'There was an issue preparing your transaction.',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
@@ -68,32 +67,23 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
|
||||
const donationMethods = [
|
||||
{
|
||||
name: "Ethereum",
|
||||
name: 'Ethereum',
|
||||
icon: FaEthereum,
|
||||
},
|
||||
{
|
||||
name: "Bitcoin",
|
||||
name: 'Bitcoin',
|
||||
icon: FaBitcoin,
|
||||
},
|
||||
{
|
||||
name: "Dogecoin",
|
||||
name: 'Dogecoin',
|
||||
icon: DogecoinIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
motionPreset="slideInBottom"
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<ModalOverlay
|
||||
bg='bg.primary'
|
||||
backdropFilter='blur(10px) hue-rotate(90deg)'
|
||||
/>
|
||||
<ModalContent bg="bg.primary" color="text.primary" >
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md" motionPreset="slideInBottom" zIndex={zIndex}>
|
||||
<ModalOverlay bg="bg.primary" backdropFilter="blur(10px) hue-rotate(90deg)" />
|
||||
<ModalContent bg="bg.primary" color="text.primary">
|
||||
<ModalHeader textAlign="center" mb={2}>
|
||||
Support
|
||||
</ModalHeader>
|
||||
@@ -109,38 +99,38 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
// colorScheme="teal"
|
||||
isFitted
|
||||
>
|
||||
<TabList mb={2} w={"20%"}>
|
||||
{donationMethods.map((method) => (
|
||||
<TabList mb={2} w={'20%'}>
|
||||
{donationMethods.map(method => (
|
||||
<Tab
|
||||
p={4}
|
||||
key={method.name}
|
||||
color={"text.primary"}
|
||||
bg={clientTransactionStore.selectedMethod=== method.name ? "bg.primary": "bg.secondary"}
|
||||
color={'text.primary'}
|
||||
bg={
|
||||
clientTransactionStore.selectedMethod === method.name
|
||||
? 'bg.primary'
|
||||
: 'bg.secondary'
|
||||
}
|
||||
onClick={() => {
|
||||
clientTransactionStore.setSelectedMethod(method.name);
|
||||
}}
|
||||
>
|
||||
<Box p={1} w={"fit-content"} >
|
||||
<method.icon />{" "}
|
||||
<Box p={1} w={'fit-content'}>
|
||||
<method.icon />{' '}
|
||||
</Box>
|
||||
{method.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{donationMethods.map((method) => (
|
||||
{donationMethods.map(method => (
|
||||
<TabPanel key={method.name}>
|
||||
{!clientTransactionStore.userConfirmed ? (
|
||||
<VStack spacing={4}>
|
||||
<Text>Enter your information:</Text>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={
|
||||
clientTransactionStore.donerId as string | undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
clientTransactionStore.setDonerId(e.target.value)
|
||||
}
|
||||
value={clientTransactionStore.donerId as string | undefined}
|
||||
onChange={e => clientTransactionStore.setDonerId(e.target.value)}
|
||||
type="text"
|
||||
bg="bg.secondary"
|
||||
color="text.primary"
|
||||
@@ -149,12 +139,8 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
<Text>Enter the amount you wish to donate:</Text>
|
||||
<Input
|
||||
placeholder="Enter amount"
|
||||
value={
|
||||
clientTransactionStore.amount as number | undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
clientTransactionStore.setAmount(e.target.value)
|
||||
}
|
||||
value={clientTransactionStore.amount as number | undefined}
|
||||
onChange={e => clientTransactionStore.setAmount(e.target.value)}
|
||||
type="number"
|
||||
bg="bg.secondary"
|
||||
// color="white"
|
||||
@@ -170,17 +156,9 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
bg="white"
|
||||
p={2}
|
||||
borderRadius="lg"
|
||||
mb={4}
|
||||
w={"min-content"}
|
||||
>
|
||||
<Box bg="white" p={2} borderRadius="lg" mb={4} w={'min-content'}>
|
||||
<QRCodeCanvas
|
||||
value={
|
||||
clientTransactionStore.depositAddress as string
|
||||
}
|
||||
value={clientTransactionStore.depositAddress as string}
|
||||
size={180}
|
||||
/>
|
||||
</Box>
|
||||
@@ -204,7 +182,7 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
// colorScheme="teal"
|
||||
mb={4}
|
||||
>
|
||||
{hasCopied ? "Address Copied!" : "Copy Address"}
|
||||
{hasCopied ? 'Address Copied!' : 'Copy Address'}
|
||||
</Button>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
Transaction ID: {clientTransactionStore.txId}
|
||||
@@ -218,7 +196,7 @@ const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" mr={3} onClick={onClose} >
|
||||
<Button variant="outline" mr={3} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import SupportThisSiteButton from "./SupportThisSiteButton";
|
||||
import GithubButton from "./GithubButton";
|
||||
import BuiltWithButton from "../BuiltWithButton";
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import BuiltWithButton from '../BuiltWithButton';
|
||||
|
||||
import GithubButton from './GithubButton';
|
||||
import SupportThisSiteButton from './SupportThisSiteButton';
|
||||
|
||||
const toolbarButtonZIndex = 901;
|
||||
|
||||
@@ -11,8 +13,8 @@ export { toolbarButtonZIndex };
|
||||
function ToolBar({ isMobile }) {
|
||||
return (
|
||||
<Flex
|
||||
direction={isMobile ? "row" : "column"}
|
||||
alignItems={isMobile ? "flex-start" : "flex-end"}
|
||||
direction={isMobile ? 'row' : 'column'}
|
||||
alignItems={isMobile ? 'flex-start' : 'flex-end'}
|
||||
pb={4}
|
||||
>
|
||||
<SupportThisSiteButton />
|
||||
|
Reference in New Issue
Block a user