adds eslint

This commit is contained in:
geoffsee
2025-06-24 17:29:52 -04:00
committed by Geoff Seemueller
parent 9698fc6f3b
commit 02c3253343
169 changed files with 4896 additions and 4804 deletions

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
);
};

View File

@@ -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>
))}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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 = [

View File

@@ -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,
});

View File

@@ -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');
}

View File

@@ -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} />;

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
});

View File

@@ -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>;
};

View File

@@ -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);

View File

@@ -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' }}
/>
));

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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',
}}
/>
);

View File

@@ -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

View File

@@ -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>

View File

@@ -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%"

View File

@@ -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 (

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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',
},
}}
/>

View File

@@ -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>

View File

@@ -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 />