This commit is contained in:
geoffsee
2025-05-22 23:14:01 -04:00
commit 33679583af
242 changed files with 15090 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import React from "react";
import { IconButton } from "@chakra-ui/react";
import { LucideHammer } from "lucide-react";
import { toolbarButtonZIndex } from "./toolbar/Toolbar";
export default function BuiltWithButton() {
return (
<IconButton
aria-label="Build Info"
icon={<LucideHammer />}
size="md"
bg="transparent"
stroke="text.accent"
color="text.accent"
onClick={() => alert("Built by Geoff Seemueller")}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
zIndex={toolbarButtonZIndex}
/>
);
}

View File

@@ -0,0 +1,53 @@
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";
export function ThemeSelectionOptions() {
const children = [];
const isMobile = useIsMobile();
for (const theme of getColorThemes()) {
children.push(
<IconButton
as="div"
key={theme.name}
onClick={() => userOptionsStore.selectTheme(theme.name)}
size="xs"
icon={
<Circle
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",
}}
/>
}
bg="transparent"
borderRadius="50%" // Ensures the button has a circular shape
stroke="transparent"
color="transparent"
_hover={{
svg: {
transition: "stroke 0.3s ease-in-out", // Smooth transition effect
},
}}
zIndex={toolbarButtonZIndex}
/>,
);
}
return (
<VStack align={!isMobile ? "end" : "start"} p={1.2}>
<Center>{children}</Center>
</VStack>
);
}

View File

@@ -0,0 +1,84 @@
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 CustomMarkdownRenderer, {
WelcomeHomeMarkdownRenderer,
} from "./chat/CustomMarkdownRenderer";
function WelcomeHomeMessage({ visible }) {
const containerVariants = {
visible: {
transition: {
staggerChildren: 0.15,
},
},
hidden: {
transition: {
staggerChildren: 0.05,
staggerDirection: -1,
},
},
};
const textVariants = {
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.165, 0.84, 0.44, 1],
},
},
hidden: {
opacity: 0,
y: 20,
transition: {
duration: 0.3,
ease: [0.165, 0.84, 0.44, 1],
},
},
};
return (
<Center>
<VStack spacing={8} align="center" maxW="400px">
{/* Welcome Message */}
<Box
fontSize="sm"
fontStyle="italic"
textAlign="center"
color="text.secondary"
mt={4}
>
<motion.div
variants={containerVariants}
initial="hidden"
animate={visible ? "visible" : "hidden"}
>
<Box userSelect={"none"}>
<motion.div variants={textVariants}>
<WelcomeHomeMarkdownRenderer markdown={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}
>
<CustomMarkdownRenderer markdown={welcome_home_tip} />
</Box>
</motion.div>
</VStack>
</Center>
);
}
export default WelcomeHomeMessage;

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Grid, GridItem, Image, Text } from "@chakra-ui/react";
const fontSize = "md";
function AboutComponent() {
return (
<Grid
templateColumns="1fr"
gap={4}
maxW={["100%", "100%", "100%"]}
mx="auto"
className="about-container"
>
<GridItem colSpan={1} justifySelf="center" mb={[6, 6, 8]}>
<Image
src="/me.png"
alt="Geoff Seemueller"
borderRadius="full"
boxSize={["120px", "150px"]}
objectFit="cover"
/>
</GridItem>
<GridItem
colSpan={1}
maxW={["100%", "100%", "container.md"]}
justifySelf="center"
minH={"100%"}
>
<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.
</Text>
</GridItem>
</Grid>
</GridItem>
</Grid>
);
}
export default AboutComponent;

View File

@@ -0,0 +1,40 @@
import React from "react";
import { IconButton, Tag, TagCloseButton, TagLabel } from "@chakra-ui/react";
import { PaperclipIcon } from "lucide-react";
// Add a new component for UploadedItem
export const UploadedItem: React.FC<{
url: string;
onRemove: () => void;
name: string;
}> = ({ url, onRemove, name }) => (
<Tag size="md" borderRadius="full" variant="solid" colorScheme="teal">
<TagLabel>{name || url.split("/").pop()}</TagLabel>
<TagCloseButton onClick={onRemove} />
</Tag>
);
export const AttachmentButton: React.FC<{
onClick: () => void;
disabled: boolean;
}> = ({ onClick, disabled }) => (
<IconButton
aria-label="Attach"
title="Attach"
bg="transparent"
color="text.tertiary"
icon={<PaperclipIcon size={"1.3337rem"} />}
onClick={onClick}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
variant="ghost"
size="sm"
isDisabled={disabled}
_focus={{ boxShadow: "none" }}
/>
);

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Box, Grid, GridItem } from "@chakra-ui/react";
import ChatMessages from "./ChatMessages";
import ChatInput from "./ChatInput";
import chatStore from "../../stores/ClientChatStore";
import menuState from "../../stores/AppMenuStore";
import WelcomeHomeMessage from "../WelcomeHomeMessage";
const Chat = observer(({ height, width }) => {
const scrollRef = useRef();
const [isAndroid, setIsAndroid] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
setIsAndroid(/android/i.test(window.navigator.userAgent));
}
}, []);
return (
<Grid
templateRows="1fr auto"
templateColumns="1fr"
height={height}
width={width}
gap={0}
>
<GridItem alignSelf="center" hidden={!(chatStore.messages.length < 1)}>
<WelcomeHomeMessage visible={chatStore.messages.length < 1} />
</GridItem>
<GridItem
overflow="auto"
width="100%"
maxH="100%"
ref={scrollRef}
// If there are attachments, use "100px". Otherwise, use "128px" on Android, "73px" elsewhere.
pb={
chatStore.attachments.length > 0
? "100px"
: 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}
>
<ChatInput
input={chatStore.input}
setInput={(value) => chatStore.setInput(value)}
handleSendMessage={chatStore.sendMessage}
isLoading={chatStore.isLoading}
/>
</Box>
</GridItem>
</Grid>
);
});
export default Chat;

View File

@@ -0,0 +1,157 @@
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 "./flyoutmenu/InputMenu";
import InputTextarea from "./ChatInputTextArea";
import SendButton from "./ChatInputSendButton";
import { useMaxWidth } from "../../layout/useMaxWidth";
import userOptionsStore from "../../stores/UserOptionsStore";
const ChatInput = observer(() => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const maxWidth = useMaxWidth();
const [inputValue, setInputValue] = useState<string>("");
const [containerHeight, setContainerHeight] = useState(56);
const [containerBorderRadius, setContainerBorderRadius] = useState(9999);
const [shouldFollow, setShouldFollow] = useState<boolean>(
userOptionsStore.followModeEnabled,
);
const [couldFollow, setCouldFollow] = useState<boolean>(chatStore.isLoading);
const [inputWidth, setInputWidth] = useState<string>("50%");
useEffect(() => {
setShouldFollow(chatStore.isLoading && userOptionsStore.followModeEnabled);
setCouldFollow(chatStore.isLoading);
}, [chatStore.isLoading, userOptionsStore.followModeEnabled]);
useEffect(() => {
inputRef.current?.focus();
setInputValue(chatStore.input);
}, [chatStore.input]);
useEffect(() => {
if (containerRef.current) {
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
const newHeight = entry.target.clientHeight;
setContainerHeight(newHeight);
const newBorderRadius = Math.max(28 - (newHeight - 56) * 0.2, 16);
setContainerBorderRadius(newBorderRadius);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
chatStore.sendMessage();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
chatStore.sendMessage();
}
};
const inputMaxWidth = useBreakpointValue(
{ base: "50rem", lg: "50rem", md: "80%", sm: "100vw" },
{ ssr: true },
);
const inputMinWidth = useBreakpointValue({ lg: "40rem" }, { ssr: true });
useEffect(() => {
setInputWidth("100%");
}, [inputMaxWidth, inputMinWidth]);
return (
<Box
width={inputWidth}
maxW={inputMaxWidth}
minWidth={inputMinWidth}
mx="auto"
p={2}
pl={2}
pb={`calc(env(safe-area-inset-bottom) + 16px)`}
bottom={0}
position="fixed"
zIndex={1000}
>
{couldFollow && (
<Box
position="absolute"
top={-8}
right={0}
zIndex={1001}
display="flex"
justifyContent="flex-end"
>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(_) => {
userOptionsStore.toggleFollowMode();
}}
isDisabled={!chatStore.isLoading}
>
{shouldFollow ? "Disable Follow Mode" : "Enable Follow Mode"}
</Button>
</Box>
)}
<Grid
ref={containerRef}
p={2}
bg="background.secondary"
borderRadius={`${containerBorderRadius}px`}
templateColumns="auto 1fr auto"
gap={2}
alignItems="center"
style={{
transition: "border-radius 0.2s ease",
}}
>
<GridItem>
<InputMenu
selectedModel={chatStore.model}
onSelectModel={chatStore.setModel}
isDisabled={chatStore.isLoading}
/>
</GridItem>
<GridItem>
<InputTextarea
inputRef={inputRef}
value={chatStore.input}
onChange={chatStore.setInput}
onKeyDown={handleKeyDown}
isLoading={chatStore.isLoading}
/>
</GridItem>
<GridItem>
<SendButton
isLoading={chatStore.isLoading}
isDisabled={chatStore.isLoading || !chatStore.input.trim()}
onClick={handleSubmit}
/>
</GridItem>
</Grid>
</Box>
);
});
export default ChatInput;

View File

@@ -0,0 +1,55 @@
import React from "react";
import { Button } from "@chakra-ui/react";
import clientChatStore from "../../stores/ClientChatStore";
import { CirclePause, Send } from "lucide-react";
import { motion } from "framer-motion";
interface SendButtonProps {
isLoading: boolean;
isDisabled: boolean;
onClick: (e: React.FormEvent) => void;
onStop?: () => void;
}
const SendButton: React.FC<SendButtonProps> = ({ onClick }) => {
const isDisabled =
clientChatStore.input.trim().length === 0 && !clientChatStore.isLoading;
return (
<Button
onClick={(e) =>
clientChatStore.isLoading
? clientChatStore.stopIncomingMessage()
: onClick(e)
}
bg="transparent"
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" }}
>
{clientChatStore.isLoading ? <MySpinner /> : <Send size={20} />}
</Button>
);
};
const MySpinner = ({ onClick }) => (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<CirclePause color={"#F0F0F0"} size={24} onClick={onClick} />
</motion.div>
);
export default SendButton;

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import {
Alert,
AlertIcon,
Box,
chakra,
HStack,
InputGroup,
} from "@chakra-ui/react";
import fileUploadStore from "../../stores/FileUploadStore";
import { UploadedItem } from "./Attachments";
import AutoResize from "react-textarea-autosize";
const AutoResizeTextArea = chakra(AutoResize);
interface InputTextAreaProps {
inputRef: React.RefObject<HTMLTextAreaElement>;
value: string;
onChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
isLoading: boolean;
}
const InputTextArea: React.FC<InputTextAreaProps> = observer(
({ inputRef, value, onChange, onKeyDown, isLoading }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAttachmentClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
fileUploadStore.uploadFile(file, "/api/documents");
}
};
const handleRemoveUploadedItem = (url: string) => {
fileUploadStore.removeUploadedFile(url);
};
const [heightConstraint, setHeightConstraint] = useState<
number | undefined
>(10);
useEffect(() => {
if (value.length > 10) {
setHeightConstraint();
}
}, [value]);
return (
<Box
position="relative"
width="100%"
height={heightConstraint}
display="flex"
flexDirection="column"
>
{/* Attachments Section */}
{fileUploadStore.uploadResults.length > 0 && (
<HStack
spacing={2}
mb={2}
overflowX="auto"
css={{ "&::-webkit-scrollbar": { display: "none" } }}
// Ensure attachments wrap if needed
flexWrap="wrap"
>
{fileUploadStore.uploadResults.map((result) => (
<UploadedItem
key={result.url}
url={result.url}
name={result.name}
onRemove={() => handleRemoveUploadedItem(result.url)}
/>
))}
</HStack>
)}
{/* Input Area */}
<InputGroup position="relative">
<AutoResizeTextArea
fontFamily="Arial, sans-serif"
ref={inputRef}
value={value}
height={heightConstraint}
autoFocus
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
p={2}
pr="8px"
pl="17px"
bg="rgba(255, 255, 255, 0.15)"
color="text.primary"
borderRadius="20px" // Set a consistent border radius
border="none"
placeholder="Free my mind..."
_placeholder={{ color: "gray.400" }}
_focus={{
outline: "none",
}}
disabled={isLoading}
minRows={1}
maxRows={12}
style={{
touchAction: "none",
resize: "none",
overflowY: "auto",
width: "100%",
transition: "height 0.2s ease-in-out",
}}
/>
{/*<InputRightElement*/}
{/* position="absolute"*/}
{/* right={0}*/}
{/* top={0}*/}
{/* bottom={0}*/}
{/* width="40px"*/}
{/* height="100%"*/}
{/* display="flex"*/}
{/* alignItems="center"*/}
{/* justifyContent="center"*/}
{/*>*/}
{/*<EnableSearchButton />*/}
{/*</InputRightElement>*/}
</InputGroup>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
{fileUploadStore.uploadError && (
<Alert status="error" mt={2}>
<AlertIcon />
{fileUploadStore.uploadError}
</Alert>
)}
</Box>
);
},
);
export default InputTextArea;

View File

@@ -0,0 +1,9 @@
import React from "react";
import CustomMarkdownRenderer from "./CustomMarkdownRenderer";
const ChatMessageContent = ({ content }) => {
return <CustomMarkdownRenderer markdown={content} />;
};
export default React.memo(ChatMessageContent);

View File

@@ -0,0 +1,56 @@
import React, { useEffect } 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";
interface ChatMessagesProps {
scrollRef: React.RefObject<HTMLDivElement>;
}
const ChatMessages: React.FC<ChatMessagesProps> = observer(({ scrollRef }) => {
const isMobile = useIsMobile();
useEffect(() => {
(async () => {
await import("./math.css");
})();
}, []);
return (
<Box
pt={isMobile ? 24 : undefined}
overflowY={"scroll"}
overflowX={"hidden"}
>
<Grid
fontFamily="Arial, sans-serif"
templateColumns="1fr"
gap={2}
bg="transparent"
borderRadius="md"
boxShadow="md"
whiteSpace="pre-wrap"
>
{chatStore.messages.map((msg, index) => {
if (index < chatStore.messages.length - 1) {
return (
<GridItem key={index}>
<MessageBubble x scrollRef={scrollRef} msg={msg} />
</GridItem>
);
} else {
return (
<GridItem key={index} mb={isMobile ? 4 : undefined}>
<MessageBubble scrollRef={scrollRef} msg={msg} />
</GridItem>
);
}
})}
</Grid>
</Box>
);
});
export default ChatMessages;

View File

@@ -0,0 +1,22 @@
import React from "react";
import { renderCustomComponents } from "./renderCustomComponents";
import { renderCustomComponents as renderWelcomeHomeMarkdown } from "./RenderWelcomeHomeCustomComponents";
import { Box } from "@chakra-ui/react";
interface CustomMarkdownRendererProps {
markdown: string;
}
const CustomMarkdownRenderer: React.FC<CustomMarkdownRendererProps> = ({
markdown,
}) => {
return <div>{renderCustomComponents(markdown)}</div>;
};
export const WelcomeHomeMarkdownRenderer: React.FC<
CustomMarkdownRendererProps
> = ({ markdown }) => {
return <Box color="text.accent">{renderWelcomeHomeMarkdown(markdown)}</Box>;
};
export default CustomMarkdownRenderer;

View File

@@ -0,0 +1,49 @@
import React from "react";
import { IconButton } from "@chakra-ui/react";
import { Globe2Icon } from "lucide-react";
import clientChatStore from "../../stores/ClientChatStore";
import { observer } from "mobx-react-lite";
export const EnableSearchButton: React.FC<{ disabled: boolean }> = observer(
({ disabled }) => {
const onClick = () => {
if (clientChatStore.tools.includes("web-search")) {
clientChatStore.setTools([]);
} else {
clientChatStore.setTools(["web-search"]);
}
};
const isActive = clientChatStore.tools.includes("web-search");
return (
<IconButton
aria-label={isActive ? "Disable Search" : "Enable Search"}
title={isActive ? "Disable Search" : "Enable Search"}
bg="transparent"
color="text.tertiary"
icon={<Globe2Icon size={"1.3337rem"} />}
onClick={onClick}
isActive={isActive}
_active={{
bg: "transparent",
svg: {
stroke: "brand.100",
transition: "stroke 0.3s ease-in-out",
},
}}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
variant="ghost"
size="sm"
isDisabled={disabled}
_focus={{ boxShadow: "none" }}
/>
);
},
);

View File

@@ -0,0 +1,135 @@
import React, { useRef } from "react";
import { observer } from "mobx-react-lite";
import {
Box,
Divider,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Text,
useDisclosure,
useOutsideClick,
} from "@chakra-ui/react";
import { ChevronRight } from "lucide-react";
import { useIsMobile } from "../contexts/MobileContext";
const FlyoutSubMenu: React.FC<{
title: string;
flyoutMenuOptions: { name: string; value: string }[];
onClose: () => void;
handleSelect: (item) => Promise<void>;
isSelected?: (item) => boolean;
parentIsOpen: boolean;
}> = observer(
({
title,
flyoutMenuOptions,
onClose,
handleSelect,
isSelected,
parentIsOpen,
}) => {
const { isOpen, onOpen, onClose: onSubMenuClose } = useDisclosure();
const isMobile = useIsMobile();
const menuRef = new useRef();
useOutsideClick({
ref: menuRef,
enabled: !isMobile,
handler: () => {
onSubMenuClose();
},
});
return (
<Menu
placement="right-start"
isOpen={isOpen && parentIsOpen}
closeOnBlur={true}
onClose={() => {
onSubMenuClose();
}}
closeOnSelect={false}
>
<MenuButton
as={MenuItem}
onClick={onOpen}
ref={menuRef}
bg="background.tertiary"
color="text.primary"
_hover={{ bg: "rgba(0, 0, 0, 0.05)" }}
_focus={{ bg: "rgba(0, 0, 0, 0.1)" }}
>
<HStack width={"100%"} justifyContent={"space-between"}>
<Text>{title}</Text>
<ChevronRight size={"1rem"} />
</HStack>
</MenuButton>
<Portal>
<MenuList
key={title}
maxHeight={56}
overflowY="scroll"
visibility={"visible"}
minWidth="180px"
bg="background.tertiary"
boxShadow="lg"
transform="translateY(-50%)"
zIndex={9999}
position="absolute"
left="100%"
bottom={-10}
sx={{
"::-webkit-scrollbar": {
width: "8px",
},
"::-webkit-scrollbar-thumb": {
background: "background.primary",
borderRadius: "4px",
},
"::-webkit-scrollbar-track": {
background: "background.tertiary",
},
}}
>
{flyoutMenuOptions.map((item, index) => (
<Box key={"itemflybox" + index}>
<MenuItem
key={"itemfly" + index}
onClick={() => {
handleSelect(item);
onSubMenuClose();
onClose();
}}
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%"}
/>
)}
</Box>
))}
</MenuList>
</Portal>
</Menu>
);
},
);
export default FlyoutSubMenu;

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect } from "react";
import { Image, Box, Spinner, Text, Flex } from "@chakra-ui/react";
import { keyframes } from "@emotion/react";
const shimmer = keyframes`
0% { background-position: -100% 0; }
100% { background-position: 100% 0; }
`;
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 handleImageLoad = () => setIsLoading(false);
const handleImageError = () => {
setIsLoading(false);
props.onError?.();
};
useEffect(() => {
setIsLoading(true);
}, [src]);
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY;
setScrollPosition(scrolled);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
const parallaxOffset = scrollPosition * 0.2;
return (
<Box
position="relative"
w="full"
maxW="full"
borderRadius="md"
my={2}
overflow="hidden"
>
{isLoading && isSlowLoadingSource && (
<Flex
align="center"
justify="center"
direction="column"
w="full"
h="300px"
borderRadius="md"
bg="background.secondary"
backgroundImage="linear-gradient(90deg, rgba(51,51,51,0.2) 25%, rgba(34,34,34,0.4) 50%, rgba(51,51,51,0.2) 75%)"
backgroundSize="200% 100%"
animation={`${shimmer} 1.5s infinite`}
>
<Spinner size="xl" color="blue.500" mb={4} />
<Text fontSize="lg" color="gray.600">
Generating...
</Text>
</Flex>
)}
<Image
src={src}
alt={alt}
fallbackSrc={fallbackSrc}
onLoad={handleImageLoad}
onError={handleImageError}
display={isLoading ? "none" : "block"}
transform={`translateY(${parallaxOffset}px)`}
transition="transform 0.1s ease-out"
{...props}
/>
</Box>
);
};
export default ImageWithFallback;

View File

@@ -0,0 +1,51 @@
import React from "react";
import { observer } from "mobx-react-lite";
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": {
return <WebSearchResult key={index} data={step.data} />;
}
case "tool-result":
return <ToolResult key={index} data={step.data} />;
default:
return <GenericStep key={index} data={step.data} />;
}
})}
</div>
);
});
const WebSearchResult = () => {
return (
<div>
{/*{webResults?.map(r => <Box>*/}
{/* <Text>{r.title}</Text>*/}
{/* <Text>{r.url}</Text>*/}
{/* <Text>{r.snippet}</Text>*/}
{/*</Box>)}*/}
</div>
);
};
export const ToolResult = ({ data }) => {
return (
<div className="tool-result">
<h3>Tool Result</h3>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
};
export const GenericStep = ({ data }) => {
return (
<div className="generic-step">
<h3>Generic Step</h3>
<p>{data.description || "No additional information provided."}</p>
</div>
);
};

View File

@@ -0,0 +1,160 @@
import React, { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { Box, Flex, Text } from "@chakra-ui/react";
import MessageRenderer from "./ChatMessageContent";
import { observer } from "mobx-react-lite";
import { IntermediateStepsComponent } from "./IntermediateStepsComponent";
import MessageEditor from "./MessageEditorComponent";
import UserMessageTools from "./UserMessageTools";
import clientChatStore from "../../stores/ClientChatStore";
import UserOptionsStore from "../../stores/UserOptionsStore";
const MotionBox = motion(Box);
const LoadingDots = () => (
<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") {
return (
<Text as="p" fontSize="sm" lineHeight="short" color="text.primary">
{msg.content}
</Text>
);
}
return <MessageRenderer content={msg.content} />;
}
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 isLoading = !msg.content || !(msg.content.trim().length > 0);
const messageRef = useRef();
const handleEdit = () => {
setIsEditing(true);
};
const handleCancelEdit = () => {
setIsEditing(false);
};
useEffect(() => {
if (
clientChatStore.messages.length > 0 &&
clientChatStore.isLoading &&
UserOptionsStore.followModeEnabled
) {
console.log(
`${clientChatStore.messages.length}/${clientChatStore.isLoading}/${UserOptionsStore.followModeEnabled}`,
);
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "auto",
});
}
});
return (
<Flex
flexDirection="column"
alignItems={isUser ? "flex-end" : "flex-start"}
role="listitem"
flex={0}
aria-label={`Message from ${senderName}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Text
fontSize="xs"
color="text.tertiary"
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%" }}
p={3}
borderRadius="1.5em"
bg={isUser ? "#0A84FF" : "#3A3A3C"}
color="text.primary"
textAlign="left"
boxShadow="0 2px 4px rgba(0, 0, 0, 0.1)"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
overflow="hidden"
wordBreak="break-word"
whiteSpace="pre-wrap"
>
<Flex justifyContent="space-between" alignItems="center">
<Box
flex="1"
overflowWrap="break-word"
whiteSpace="pre-wrap"
ref={messageRef}
sx={{
"pre, code": {
maxWidth: "100%",
whiteSpace: "pre-wrap",
overflowX: "auto",
},
}}
>
<IntermediateStepsComponent hidden={msg.role === "user"} />
{isEditing ? (
<MessageEditor message={msg} onCancel={handleCancelEdit} />
) : isLoading ? (
<LoadingDots />
) : (
renderMessage(msg)
)}
</Box>
{isUser && (
<Box
ml={2}
width="32px"
height="32px"
display="flex"
justifyContent="center"
alignItems="center"
>
{isHovered && !isEditing && (
<UserMessageTools message={msg} onEdit={handleEdit} />
)}
</Box>
)}
</Flex>
</MotionBox>
</Flex>
);
});
export default MessageBubble;

View File

@@ -0,0 +1,72 @@
import React, { KeyboardEvent, useState } from "react";
import { Box, Flex, IconButton, Textarea } from "@chakra-ui/react";
import { Check, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import store, { type IMessage } from "../../stores/ClientChatStore";
interface MessageEditorProps {
message: IMessage;
onCancel: () => void;
}
const MessageEditor = observer(({ message, onCancel }: MessageEditorProps) => {
const [editedContent, setEditedContent] = useState(message.content);
const handleSave = () => {
const messageIndex = store.messages.indexOf(message);
if (messageIndex !== -1) {
store.editMessage(messageIndex, editedContent);
}
onCancel();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSave();
}
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<Box width="100%">
<Textarea
value={editedContent}
onChange={(e) => 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" }}
resize="vertical"
color="text.primary"
/>
<Flex justify="flex-end" mt={2} gap={2}>
<IconButton
aria-label="Cancel edit"
icon={<X />}
onClick={onCancel}
size="sm"
variant="ghost"
color={"accent.danger"}
/>
<IconButton
aria-label="Save edit"
icon={<Check />}
onClick={handleSave}
size="sm"
variant="ghost"
color={"accent.confirm"}
/>
</Flex>
</Box>
);
});
export default MessageEditor;

View File

@@ -0,0 +1,156 @@
import React, { useCallback } from "react";
import {
Box,
Button,
Divider,
Flex,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { observer } from "mobx-react-lite";
import { ChevronDown, Copy, RefreshCcw, Settings } from "lucide-react";
import ClientChatStore from "../../stores/ClientChatStore";
import clientChatStore from "../../stores/ClientChatStore";
import FlyoutSubMenu from "./FlyoutSubMenu";
import { useIsMobile } from "../contexts/MobileContext";
import { getModelFamily, SUPPORTED_MODELS } from "./SupportedModels";
import { formatConversationMarkdown } from "./exportConversationAsMarkdown";
// Common styles for MenuButton and IconButton
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" },
};
const InputMenu: React.FC<{ isDisabled?: boolean }> = observer(
({ isDisabled }) => {
const isMobile = useIsMobile();
const { isOpen, onOpen, onClose } = useDisclosure();
const textModels = SUPPORTED_MODELS;
const handleCopyConversation = useCallback(() => {
navigator.clipboard
.writeText(formatConversationMarkdown(ClientChatStore.messages))
.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 }) {
if (getModelFamily(value)) {
ClientChatStore.setModel(value);
}
}
function isSelectedModelFn({ name, value }) {
return ClientChatStore.model === value;
}
return (
<Menu
isOpen={isOpen}
onClose={onClose}
onOpen={onOpen}
closeOnSelect={false}
closeOnBlur={true}
>
{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"}
>
<Divider color="text.tertiary" />
<FlyoutSubMenu
title="Text Models"
flyoutMenuOptions={textModels.map((m) => ({ name: m, value: m }))}
onClose={onClose}
parentIsOpen={isOpen}
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={() => {
onClose();
clientChatStore.reset();
}}
_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

@@ -0,0 +1,576 @@
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";
import CodeBlock from "../code/CodeBlock";
import ImageWithFallback from "./ImageWithFallback";
import markedKatex from "marked-katex-extension";
import katex from "katex";
try {
if (localStorage) {
marked.use(
markedKatex({
nonStandard: false,
displayMode: true,
throwOnError: false,
strict: true,
colorIsTextColor: true,
errorColor: "red",
}),
);
}
} catch (_) {}
const MemoizedCodeBlock = React.memo(CodeBlock);
/**
* Utility to map heading depth to Chakra heading styles that
* roughly match typical markdown usage.
*/
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 };
}
};
interface TableToken extends marked.Tokens.Table {
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%">
{text}
</Heading>
);
};
const CustomParagraph: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
return (
<Text
as="p"
fontSize="sm"
color="text.accent"
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 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 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,
};
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>;
};
const CustomKatex: React.FC<{ math: string; displayMode: boolean }> = ({
math,
displayMode,
}) => {
const renderedMath = katex.renderToString(math, { displayMode });
return (
<Box
as="span"
display={displayMode ? "block" : "inline"}
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>
);
};
const CustomHtmlBlock: React.FC<{ content: string }> = ({ content }) => {
return <Box dangerouslySetInnerHTML={{ __html: content }} mb={2} />;
};
const CustomText: React.FC<{ text: React.ReactNode }> = ({ text }) => {
return (
<Text
fontSize="sm"
lineHeight="short"
wordBreak="break-word"
maxWidth="100%"
as="span"
>
{text}
</Text>
);
};
interface CustomStrongProps {
children: React.ReactNode;
}
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 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 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>
);
};
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>
);
};
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}
/>
);
};
/**
* A helper function that iterates through a list of Marked tokens
* and returns an array of React elements. This is the heart of the
* custom-rendering logic, used both top-level and for nested tokens.
*/
function parseTokens(tokens: marked.Token[]): 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;
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);
}
});
return output;
}
export function renderCustomComponents(markdown: string): JSX.Element[] {
marked.setOptions({
breaks: true,
gfm: true,
silent: false,
async: true,
});
const tokens = marked.lexer(markdown);
return parseTokens(tokens);
}

View File

@@ -0,0 +1,574 @@
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";
import CodeBlock from "../code/CodeBlock";
import ImageWithFallback from "./ImageWithFallback";
import markedKatex from "marked-katex-extension";
import katex from "katex";
try {
if (localStorage) {
marked.use(
markedKatex({
nonStandard: false,
displayMode: true,
throwOnError: false,
strict: true,
colorIsTextColor: true,
errorColor: "red",
}),
);
}
} catch (_) {}
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 };
}
};
interface TableToken extends marked.Tokens.Table {
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 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 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 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,
};
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>;
};
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 }}
/>
);
};
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>
);
};
const CustomHtmlBlock: React.FC<{ content: string }> = ({ content }) => {
return <Box 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>
);
};
interface CustomStrongProps {
children: React.ReactNode;
}
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 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 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>
);
};
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>
);
};
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}
/>
);
};
function parseTokens(tokens: marked.Token[]): 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;
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);
}
});
return output;
}
export function renderCustomComponents(markdown: string): JSX.Element[] {
marked.setOptions({
breaks: true,
gfm: true,
silent: false,
async: true,
});
const tokens = marked.lexer(markdown);
return parseTokens(tokens);
}

View File

@@ -0,0 +1,88 @@
const SUPPORTED_MODELS_GROUPS = {
openai: [
// "o1-preview",
// "o1-mini",
// "gpt-4o",
// "gpt-3.5-turbo"
],
groq: [
// "mixtral-8x7b-32768",
// "deepseek-r1-distill-llama-70b",
"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.1-70b-versatile",
// "llama-3.3-70b-versatile"
],
cerebras: ["llama-3.3-70b"],
claude: [
// "claude-3-5-sonnet-20241022",
// "claude-3-opus-20240229"
],
fireworks: [
// "llama-v3p1-405b-instruct",
// "llama-v3p1-70b-instruct",
// "llama-v3p2-90b-vision-instruct",
// "mixtral-8x22b-instruct",
// "mythomax-l2-13b",
// "yi-large"
],
google: [
// "gemini-2.0-flash-exp",
// "gemini-1.5-flash",
// "gemini-exp-1206",
// "gemini-1.5-pro"
],
xai: [
// "grok-beta",
// "grok-2",
// "grok-2-1212",
// "grok-2-latest",
// "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",
// "gemma-7b-it",
],
};
export type SupportedModel =
| 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;
}
const SUPPORTED_MODELS = [
// ...SUPPORTED_MODELS_GROUPS.xai,
// ...SUPPORTED_MODELS_GROUPS.claude,
// ...SUPPORTED_MODELS_GROUPS.google,
...SUPPORTED_MODELS_GROUPS.groq,
...SUPPORTED_MODELS_GROUPS.fireworks,
// ...SUPPORTED_MODELS_GROUPS.openai,
...SUPPORTED_MODELS_GROUPS.cerebras,
...SUPPORTED_MODELS_GROUPS.cloudflareAI,
];
export { SUPPORTED_MODELS, SUPPORTED_MODELS_GROUPS, getModelFamily };

View File

@@ -0,0 +1,34 @@
import { observer } from "mobx-react-lite";
import { IconButton } from "@chakra-ui/react";
import { Edit2Icon } from "lucide-react";
const UserMessageTools = observer(({ disabled = false, message, onEdit }) => (
<IconButton
bg="transparent"
color="text.primary"
aria-label="Edit message"
title="Edit message"
icon={<Edit2Icon size={"1em"} />}
onClick={() => onEdit(message)}
_active={{
bg: "transparent",
svg: {
stroke: "brand.100",
transition: "stroke 0.3s ease-in-out",
},
}}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
variant="ghost"
size="sm"
isDisabled={disabled}
_focus={{ boxShadow: "none" }}
/>
));
export default UserMessageTools;

View File

@@ -0,0 +1,33 @@
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",
],
ALLOWED_ATTR: ["href", "src", "alt", "title", "class", "style"],
FORBID_TAGS: ["script", "iframe"],
KEEP_CONTENT: true,
SAFE_FOR_TEMPLATES: true,
});
}
export default domPurify;

View File

@@ -0,0 +1,18 @@
// Function to generate a Markdown representation of the current conversation
import { type IMessage } from "../../stores/ClientChatStore";
import { Instance } from "mobx-state-tree";
export function formatConversationMarkdown(
messages: Instance<typeof IMessage>[],
): string {
return messages
.map((message) => {
if (message.role === "user") {
return `**You**: ${message.content}`;
} else if (message.role === "assistant") {
return `**Geoff's AI**: ${message.content}`;
}
return "";
})
.join("\n\n");
}

View File

@@ -0,0 +1,127 @@
import React, { useRef } from "react";
import { observer } from "mobx-react-lite";
import {
Box,
Divider,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { ChevronRight } from "lucide-react";
const FlyoutSubMenu: React.FC<{
title: string;
flyoutMenuOptions: { name: string; value: string }[];
onClose: () => void;
handleSelect: (item) => Promise<void>;
isSelected?: (item) => boolean;
parentIsOpen: boolean;
setMenuState?: (state) => void;
}> = observer(
({
title,
flyoutMenuOptions,
onClose,
handleSelect,
isSelected,
parentIsOpen,
setMenuState,
}) => {
const { isOpen, onOpen, onClose: onSubMenuClose } = useDisclosure();
const menuRef = new useRef();
return (
<Menu
placement="right-start"
isOpen={isOpen && parentIsOpen}
closeOnBlur={true}
lazyBehavior={"keepMounted"}
isLazy={true}
onClose={(e) => {
onSubMenuClose();
}}
closeOnSelect={false}
>
<MenuButton
as={MenuItem}
onClick={onOpen}
ref={menuRef}
bg="background.tertiary"
color="text.primary"
_hover={{ bg: "rgba(0, 0, 0, 0.05)" }}
_focus={{ bg: "rgba(0, 0, 0, 0.1)" }}
>
<HStack width={"100%"} justifyContent={"space-between"}>
<Text>{title}</Text>
<ChevronRight size={"1rem"} />
</HStack>
</MenuButton>
<Portal>
<MenuList
key={title}
maxHeight={56}
overflowY="scroll"
visibility={"visible"}
minWidth="180px"
bg="background.tertiary"
boxShadow="lg"
transform="translateY(-50%)"
zIndex={9999}
position="absolute"
left="100%"
bottom={-10}
sx={{
"::-webkit-scrollbar": {
width: "8px",
},
"::-webkit-scrollbar-thumb": {
background: "background.primary",
borderRadius: "4px",
},
"::-webkit-scrollbar-track": {
background: "background.tertiary",
},
}}
>
{flyoutMenuOptions.map((item, index) => (
<Box key={"itemflybox" + index}>
<MenuItem
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)" }}
>
{item.name}
</MenuItem>
{index < flyoutMenuOptions.length - 1 && (
<Divider
key={item.name + "-divider"}
color="text.tertiary"
w={"100%"}
/>
)}
</Box>
))}
</MenuList>
</Portal>
</Menu>
);
},
);
export default FlyoutSubMenu;

View File

@@ -0,0 +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 clientChatStore from "../../../stores/ClientChatStore";
import FlyoutSubMenu from "./FlyoutSubMenu";
import { useIsMobile } from "../../contexts/MobileContext";
import { useIsMobile as useIsMobileUserAgent } from "../../../layout/_IsMobileHook";
import { getModelFamily, SUPPORTED_MODELS } from "../SupportedModels";
import { formatConversationMarkdown } from "../exportConversationAsMarkdown";
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" },
};
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);
useEffect(() => {
setControlledOpen(isOpen);
}, [isOpen]);
const textModels = SUPPORTED_MODELS;
const handleClose = useCallback(() => {
onClose();
}, [isOpen]);
const handleCopyConversation = useCallback(() => {
navigator.clipboard
.writeText(formatConversationMarkdown(ClientChatStore.messages))
.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 }) {
if (getModelFamily(value)) {
ClientChatStore.setModel(value);
}
}
function isSelectedModelFn({ name, value }) {
return ClientChatStore.model === value;
}
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={textModels.map((m) => ({ name: m, value: m }))}
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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import { visit } from "unist-util-visit";
export default function remarkImageGeneration() {
return (tree) => {
visit(tree, "code", (node, index, parent) => {
if (node.lang === "generation") {
try {
const data = JSON.parse(node.value);
parent.children[index] = {
type: "generation",
data: data,
};
} catch (error) {
console.error("Invalid JSON in image-generation block:", error);
}
}
});
};
}

View File

@@ -0,0 +1,69 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { buildCodeHighlighter } from "./CodeHighlighter";
interface CodeBlockProps {
language: string;
code: string;
onRenderComplete: () => void;
}
const highlighter = buildCodeHighlighter();
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",
});
setHtml(highlighted);
} catch (error) {
console.error("Error highlighting code:", error);
setHtml(`<pre>${code}</pre>`);
} finally {
setLoading(false);
onRenderComplete();
}
}, [language, code, onRenderComplete]);
useEffect(() => {
highlightCode();
}, [highlightCode]);
if (loading) {
return (
<div
style={{
backgroundColor: "#24292e",
padding: "10px",
borderRadius: "1.5em",
}}
>
Loading code...
</div>
);
}
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
style={{
transition: "none",
padding: 20,
backgroundColor: "#24292e",
overflowX: "auto",
borderRadius: ".37em",
fontSize: ".75rem",
}}
/>
);
};
export default React.memo(CodeBlock);

View File

@@ -0,0 +1,75 @@
import { createHighlighterCore } from "shiki";
export async function buildCodeHighlighter() {
const [
githubDark,
html,
javascript,
jsx,
typescript,
tsx,
go,
rust,
python,
java,
kotlin,
shell,
sql,
yaml,
toml,
markdown,
json,
xml,
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"),
]);
// Create the highlighter instance with the loaded themes and languages
const instance = await createHighlighterCore({
themes: [githubDark], // Set the Base_theme
langs: [
html,
javascript,
jsx,
typescript,
tsx,
go,
rust,
python,
java,
kotlin,
shell,
sql,
yaml,
toml,
markdown,
json,
xml,
zig,
],
loadWasm: wasm, // Ensure correct loading of WebAssembly
});
return instance;
}

View File

@@ -0,0 +1,169 @@
import React from "react";
import {
Alert,
AlertIcon,
Box,
Button,
HStack,
Input,
Link,
List,
ListItem,
} from "@chakra-ui/react";
import { MarkdownEditor } from "./MarkdownEditor";
import { Fragment, useState } from "react";
function ConnectComponent() {
const [formData, setFormData] = useState({
markdown: "",
email: "",
firstname: "",
lastname: "",
});
const [isSubmitted, setIsSubmitted] = useState(false);
const [isError, setIsError] = useState(false);
const [validationError, setValidationError] = useState("");
const handleChange = (field: string) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setIsSubmitted(false);
setValidationError("");
};
const handleSubmitButton = async () => {
setValidationError("");
if (!formData.email || !formData.firstname || !formData.markdown) {
setValidationError("Please fill in all required fields.");
return;
}
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (response.ok) {
setIsSubmitted(true);
setIsError(false);
setFormData({
markdown: "",
email: "",
firstname: "",
lastname: "",
});
} else {
setIsError(true);
}
} catch (error) {
setIsError(true);
}
};
return (
<Fragment>
<List color="text.primary" mb={4}>
<ListItem>
Email:{" "}
<Link href="mailto:geoff@seemueller.io" color="teal.500">
geoff@seemueller.io
</Link>
</ListItem>
</List>
<Box w="100%">
<HStack spacing={4} mb={4}>
<Input
placeholder="First name *"
value={formData.firstname}
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)}
color="text.primary"
borderColor="text.primary"
// bg="text.primary"
/>
</HStack>
<Input
placeholder="Email *"
value={formData.email}
onChange={(e) => handleChange("email")(e.target.value)}
mb={4}
borderColor="text.primary"
color="text.primary"
/>
<MarkdownEditor
onChange={handleChange("markdown")}
markdown={formData.markdown}
placeholder="Your Message..."
/>
</Box>
<Button
variant="outline"
// colorScheme="blackAlpha"
onClick={handleSubmitButton}
alignSelf="flex-end"
size="md"
mt={4}
mb={4}
float="right"
_hover={{
bg: "",
transform: "scale(1.05)",
}}
_active={{
bg: "gray.800",
transform: "scale(1)",
}}
>
SEND
</Button>
<Box mt={12}>
{isSubmitted && (
<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"
>
<AlertIcon />
There was an error sending your message. Please try again.
</Alert>
)}
{validationError && (
<Alert
status="warning"
borderRadius="md"
color="background.primary"
bg="yellow.500"
>
<AlertIcon />
{validationError}
</Alert>
)}
</Box>
</Fragment>
);
}
export default ConnectComponent;

View File

@@ -0,0 +1,23 @@
import React from "react";
import { Box, Textarea } from "@chakra-ui/react";
export const MarkdownEditor = (props: {
placeholder: string;
markdown: string;
onChange: (p: any) => any;
}) => {
return (
<Box sx={styles}>
<link rel="stylesheet" href="/" media="print" onLoad="this.media='all'" />
<Textarea
value={props.markdown}
placeholder={props.placeholder}
onChange={(e) => props.onChange(e.target.value)}
width="100%"
minHeight="150px"
height="100%"
resize="none"
/>
</Box>
);
};

View File

@@ -0,0 +1,18 @@
import {
ChakraProvider,
cookieStorageManagerSSR,
localStorageManager,
} from "@chakra-ui/react";
export function Chakra({ cookies, children, theme }) {
const colorModeManager =
typeof cookies === "string"
? cookieStorageManagerSSR("color_state", cookies)
: localStorageManager;
return (
<ChakraProvider colorModeManager={colorModeManager} theme={theme}>
{children}
</ChakraProvider>
);
}

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { useMediaQuery } from "@chakra-ui/react";
// Create the context to provide mobile state
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)");
useEffect(() => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
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>
);
};
// Custom hook to use the mobile context in any component
export function useIsMobile() {
return useContext(MobileContext);
}
export default MobileContext;

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Badge, Box, Flex, Heading, Image, Text } from "@chakra-ui/react";
function DemoCard({ icon, title, description, imageUrl, badge, onClick }) {
return (
<Box
bg="background.secondary"
borderRadius="md"
overflowY="hidden"
boxShadow="md"
transition="transform 0.2s"
_hover={{ transform: "scale(1.05)", cursor: "pointer" }}
color="text.primary"
onClick={onClick}
display="flex"
flexDirection="column"
minW={"12rem"}
maxW={"18rem"}
minH={"35rem"}
maxH={"20rem"}
>
{imageUrl && (
<Image
src={imageUrl}
alt={title}
objectFit="cover"
minH="16rem"
maxH="20rem"
width="100%"
/>
)}
<Flex direction="column" flex="1" p={4}>
<Box display="flex" alignItems="center" mb={2}>
{icon}
<Heading as="h4" size="md" ml={2}>
{title}
</Heading>
</Box>
<Text fontSize="sm" flex="1">
{description}
</Text>
</Flex>
{badge && (
<Box p={2}>
<Badge colorScheme={"teal"}>{badge}</Badge>
</Box>
)}
</Box>
);
}
export default DemoCard;

View File

@@ -0,0 +1,38 @@
import React from "react";
import { SimpleGrid } from "@chakra-ui/react";
import { Rocket, Shield } from "lucide-react";
import DemoCard from "./DemoCard";
function DemoComponent() {
return (
<SimpleGrid
columns={{ base: 1, sm: 1, lg: 2 }}
spacing={"7%"}
minH={"min-content"}
h={"100vh"}
>
<DemoCard
icon={<Rocket size={24} color="teal" />}
title="toak"
description="A tool for turning git repositories into markdown, without their secrets"
imageUrl="/code-tokenizer-md.jpg"
badge="npm"
onClick={() => {
window.open("https://github.com/seemueller-io/toak");
}}
/>
<DemoCard
icon={<Shield size={24} color="teal" />}
title="REHOBOAM"
description="Explore the latest in AI news around the world in real-time"
imageUrl="/rehoboam.png"
badge="APP"
onClick={() => {
window.open("https://rehoboam.seemueller.io");
}}
/>
</SimpleGrid>
);
}
export default DemoComponent;

View File

@@ -0,0 +1,124 @@
import React from "react";
import {
Box,
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Textarea,
useToast,
VStack,
} from "@chakra-ui/react";
import { observer } from "mobx-react-lite";
import feedbackState from "../../stores/ClientFeedbackStore";
const FeedbackModal = observer(({ isOpen, onClose, zIndex }) => {
const toast = useToast();
const handleSubmitFeedback = async () => {
const success = await feedbackState.submitFeedback();
if (success) {
toast({
title: "Feedback Submitted",
description: "Thank you for your feedback!",
status: "success",
duration: 3000,
isClosable: true,
});
feedbackState.reset();
onClose();
} else if (feedbackState.error) {
if (!feedbackState.input.trim() || feedbackState.input.length > 500) {
return;
}
toast({
title: "Submission Failed",
description: feedbackState.error,
status: "error",
duration: 3000,
isClosable: true,
});
}
};
const handleClose = () => {
feedbackState.reset();
onClose();
};
const charactersRemaining = 500 - (feedbackState.input?.length || 0);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
size="md"
motionPreset="slideInBottom"
zIndex={zIndex}
>
<ModalOverlay />
<ModalContent bg="gray.800" color="text.primary">
<ModalHeader textAlign="center">Feedback</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<Text fontSize="md" textAlign="center">
Your thoughts help me improve. Let me know what you think!
</Text>
<Box position="relative">
<Textarea
placeholder="Type your feedback here..."
value={feedbackState.input}
onChange={(e) => feedbackState.setInput(e.target.value)}
bg="gray.700"
color="white"
minHeight="120px"
resize="vertical"
/>
<Text
position="absolute"
bottom="2"
right="2"
fontSize="xs"
color={charactersRemaining < 50 ? "orange.300" : "gray.400"}
>
{charactersRemaining} characters remaining
</Text>
</Box>
{feedbackState.error && (
<Text color="red.500" fontSize="sm">
{feedbackState.error}
</Text>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button
onClick={handleSubmitFeedback}
isLoading={feedbackState.isLoading}
colorScheme="teal"
mr={3}
disabled={feedbackState.isLoading || !feedbackState.input.trim()}
>
Submit
</Button>
<Button variant="outline" onClick={handleClose} colorScheme="gray">
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
});
export default FeedbackModal;

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Box } from "@chakra-ui/react";
const TealDogecoinIcon = (props) => (
<Box
as="svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
boxSize={props.boxSize || "1em"}
{...props}
>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
fill="#008080"
/>
<path
d="M12 21.6797C17.3459 21.6797 21.6797 17.3459 21.6797 12C21.6797 6.65405 17.3459 2.32031 12 2.32031C6.65405 2.32031 2.32031 6.65405 2.32031 12C2.32031 17.3459 6.65405 21.6797 12 21.6797Z"
fill="#009999"
/>
<path
d="M12 21.4757C17.2333 21.4757 21.4758 17.2333 21.4758 12C21.4758 6.76666 17.2333 2.52423 12 2.52423C6.76672 2.52423 2.52429 6.76666 2.52429 12C2.52429 17.2333 6.76672 21.4757 12 21.4757Z"
fill="#00CCCC"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.5001 9.54053C16.4888 6.92891 13.9888 6.44507 13.9888 6.44507H6.85606L6.88358 9.10523H8.30406V15.0454H6.85596V17.7007H13.7913C15.4628 17.7007 16.8026 16.0211 16.8026 16.0211C18.9482 12.9758 17.5 9.54053 17.5 9.54053H17.5001ZM13.8285 14.2314C13.8285 14.2314 13.2845 15.0163 12.6927 15.0163H11.5087L11.4806 9.11173H13.0001C13.0001 9.11173 13.7041 9.25894 14.1959 10.6521C14.1959 10.6521 14.848 12.6468 13.8285 14.2314Z"
fill="white"
fill-opacity="0.8"
/>
</Box>
);
export default TealDogecoinIcon;

View File

@@ -0,0 +1,23 @@
import React from "react";
import { Box, VStack } from "@chakra-ui/react";
import Markdown from "react-markdown";
import { webComponents } from "../react-markdown/WebComponents";
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}
>
<Markdown components={webComponents}>{text}</Markdown>
</Box>
</VStack>
</Box>
);
}
export default LegalDoc;

View File

@@ -0,0 +1,123 @@
import React from "react";
import {
Box,
Divider,
Heading,
Link,
List,
ListItem,
OrderedList,
Text,
UnorderedList,
} from "@chakra-ui/react";
import ImageWithFallback from "../chat/ImageWithFallback";
import { MdCheckCircle } from "react-icons/md";
export const webComponents = {
p: ({ children }) => (
<Text fontSize="sm" lineHeight="short">
{children}
</Text>
),
strong: ({ children }) => <strong>{children}</strong>,
h1: ({ children }) => (
<Heading as="h1" size="xl" mt={4} mb={2}>
{children}
</Heading>
),
h2: ({ children }) => (
<Heading as="h2" size="lg" mt={3} mb={2}>
{children}
</Heading>
),
h3: ({ children }) => (
<Heading as="h3" size="md" mt={2} mb={1}>
{children}
</Heading>
),
h4: ({ children }) => (
<Heading as="h4" size="sm" mt={2} mb={1}>
{children}
</Heading>
),
ul: ({ children }) => (
<UnorderedList
// pl={3}
// mb={2}
fontSize="sm"
// stylePosition="inside" // Keep bullets inside the text flow
>
{children}
</UnorderedList>
),
ol: ({ children }) => (
<OrderedList fontSize="sm" spacing={2}>
{children}
</OrderedList>
),
li: ({ children, ...rest }) => {
const filteredChildren = React.Children.toArray(children)
.filter((child) => !(typeof child === "string" && child.trim() === "\n"))
.map((child, index, array) => {
// if (typeof child === 'string' && index === array.length - 1 && /\n/.test(child)) {
// return '\n';
// }
return child;
});
return <ListItem {...rest}>{filteredChildren}</ListItem>;
},
pre: ({ children }) => (
<Box
as="pre"
whiteSpace="pre-wrap"
bg="background.secondary"
borderRadius="md"
fontSize="sm"
>
{children}
</Box>
),
blockquote: ({ children }) => (
<Box
as="blockquote"
borderLeft="4px solid"
borderColor="gray.200"
fontStyle="italic"
color="gray.600"
pl={4}
>
{children}
</Box>
),
hr: () => <Divider my={4} />,
a: ({ href, children }) => (
<Link
color="teal.500"
href={href}
isExternal
maxWidth="100%"
wordBreak="break-word"
>
{children}
</Link>
),
img: ({ alt, src }) => <ImageWithFallback alt={alt} src={src} />,
icon_list: ({ children }) => (
<List spacing={3}>
{React.Children.map(children, (child) => (
<ListItem>
<Box
as={MdCheckCircle}
color="green.500"
mr={2}
display="inline-block"
/>
{child}
</ListItem>
))}
</List>
),
};

View File

@@ -0,0 +1,62 @@
import React, { useCallback, useMemo } from "react";
import { Box, Flex, useMediaQuery } from "@chakra-ui/react";
import { resumeData } from "../../static-data/resume_data";
import SectionContent from "./SectionContent";
import SectionButton from "./SectionButton";
const sections = ["professionalSummary", "skills", "experience", "education"];
export default function ResumeComponent() {
const [activeSection, setActiveSection] = React.useState(
"professionalSummary",
);
const [isMobile] = useMediaQuery("(max-width: 1243px)");
const handleSectionClick = useCallback((section) => {
setActiveSection(section);
}, []);
const capitalizeFirstLetter = useCallback((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}, []);
const sectionButtons = useMemo(
() =>
sections.map((section) => (
<SectionButton
key={section}
onClick={() => handleSectionClick(section)}
activeSection={activeSection}
section={section}
mobile={isMobile}
callbackfn={capitalizeFirstLetter}
/>
)),
[activeSection, isMobile, handleSectionClick, capitalizeFirstLetter],
);
return (
<Box p={"unset"}>
<Flex
direction={isMobile ? "column" : "row"}
mb={8}
wrap="nowrap"
gap={isMobile ? 2 : 4}
minWidth="0"
>
{sectionButtons}
</Flex>
<Box
bg="background.secondary"
color="text.primary"
borderRadius="md"
boxShadow="md"
borderWidth={1}
borderColor="brand.300"
minHeight="300px"
>
<SectionContent activeSection={activeSection} resumeData={resumeData} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,32 @@
import React from "react";
import { Button } from "@chakra-ui/react";
import { ChevronRight } from "lucide-react";
function SectionButton(props: {
onClick: () => void;
activeSection: string;
section: string;
mobile: boolean;
callbackfn: (word) => string;
}) {
return (
<Button
mt={1}
onClick={props.onClick}
variant={props.activeSection === props.section ? "solid" : "outline"}
colorScheme="brand"
rightIcon={<ChevronRight size={16} />}
size="md"
width={props.mobile ? "100%" : "auto"}
>
{props.section
.replace(/([A-Z])/g, " $1")
.trim()
.split(" ")
.map(props.callbackfn)
.join(" ")}
</Button>
);
}
export default SectionButton;

View File

@@ -0,0 +1,98 @@
import React from "react";
import {
Box,
Grid,
GridItem,
Heading,
ListItem,
Text,
UnorderedList,
VStack,
} from "@chakra-ui/react";
const fontSize = "md";
const ProfessionalSummary = ({ professionalSummary }) => (
<Box>
<Grid
templateColumns="1fr"
gap={4}
maxW={["100%", "100%", "100%"]}
mx="auto"
className="about-container"
>
<GridItem
colSpan={1}
maxW={["100%", "100%", "container.md"]}
justifySelf="center"
minH={"100%"}
>
<Grid templateColumns="1fr" gap={4} overflowY={"auto"}>
<GridItem>
<Text fontSize="md">{professionalSummary}</Text>
</GridItem>
</Grid>
</GridItem>
</Grid>
</Box>
);
const Skills = ({ skills }) => (
<VStack align={"baseline"} spacing={6} mb={4}>
<UnorderedList spacing={2} mb={0}>
<Box>
{skills?.map((skill, index) => (
<ListItem p={1} key={index}>
{skill}
</ListItem>
))}
</Box>
</UnorderedList>
</VStack>
);
const Experience = ({ experience }) => (
<VStack align="start" spacing={6} mb={4}>
{experience?.map((job, index) => (
<Box key={index} width="100%">
<Heading as="h3" size="md" mb={2}>
{job.title}
</Heading>
<Text fontWeight="bold">{job.company}</Text>
<Text color="gray.500" mb={2}>
{job.timeline}
</Text>
<Text>{job.description}</Text>
</Box>
))}
</VStack>
);
const Education = ({ education }) => (
<UnorderedList spacing={2} mb={4}>
{education?.map((edu, index) => <ListItem key={index}>{edu}</ListItem>)}
</UnorderedList>
);
const SectionContent = ({ activeSection, resumeData }) => {
const components = {
professionalSummary: ProfessionalSummary,
skills: Skills,
experience: Experience,
education: Education,
};
const ActiveComponent = components[activeSection];
return (
<Box p={4} minHeight="300px" width="100%">
{ActiveComponent ? (
<ActiveComponent {...resumeData} />
) : (
<Text>Select a section to view details.</Text>
)}
</Box>
);
};
export default SectionContent;

View File

@@ -0,0 +1,64 @@
// ServicesComponent.js
import React, { useCallback, useMemo } from "react";
import { Box, Flex, useMediaQuery } from "@chakra-ui/react";
import { servicesData } from "../../static-data/services_data";
import SectionButton from "../resume/SectionButton";
import ServicesSectionContent from "./ServicesComponentSection";
const sections = ["servicesOverview", "offerings"];
export default function ServicesComponent() {
const [activeSection, setActiveSection] = React.useState("servicesOverview");
const [isMobile] = useMediaQuery("(max-width: 1243px)");
const handleSectionClick = useCallback((section) => {
setActiveSection(section);
}, []);
const capitalizeFirstLetter = useCallback((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}, []);
const sectionButtons = useMemo(
() =>
sections.map((section) => (
<SectionButton
key={section}
onClick={() => handleSectionClick(section)}
activeSection={activeSection}
section={section}
mobile={isMobile}
callbackfn={capitalizeFirstLetter}
/>
)),
[activeSection, isMobile, handleSectionClick, capitalizeFirstLetter],
);
return (
<Box p={"unset"}>
<Flex
direction={isMobile ? "column" : "row"}
mb={8}
wrap="nowrap"
gap={isMobile ? 2 : 4}
minWidth="0" // Ensures flex items can shrink if needed
>
{sectionButtons}
</Flex>
<Box
bg="background.secondary"
color="text.primary"
borderRadius="md"
boxShadow="md"
borderWidth={1}
borderColor="brand.300"
minHeight="300px"
>
<ServicesSectionContent
activeSection={activeSection}
data={servicesData}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,40 @@
import React from "react";
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
const ServicesOverview = ({ servicesOverview }) => (
<Text fontSize="md">{servicesOverview}</Text>
);
const Offerings = ({ offerings }) => (
<VStack align="start" spacing={6} mb={4}>
{offerings.map((service, index) => (
<Box key={index}>
<Heading as="h3" size="md" mb={2}>
{service.title}
</Heading>
<Text mb={4}>{service.description}</Text>
</Box>
))}
</VStack>
);
const ServicesSectionContent = ({ activeSection, data }) => {
const components = {
servicesOverview: ServicesOverview,
offerings: Offerings,
};
const ActiveComponent = components[activeSection];
return (
<Box p={4} minHeight="300px" width="100%">
{ActiveComponent ? (
<ActiveComponent {...data} />
) : (
<Text>Select a section to view details.</Text>
)}
</Box>
);
};
export default ServicesSectionContent;

View File

@@ -0,0 +1,29 @@
import React from "react";
import { IconButton } from "@chakra-ui/react";
import { Github } from "lucide-react";
import { toolbarButtonZIndex } from "./Toolbar";
export default function GithubButton() {
return (
<IconButton
as="a"
href="https://github.com/geoffsee"
target="_blank"
aria-label="GitHub"
icon={<Github />}
size="md"
bg="transparent"
stroke="text.accent"
color="text.accent"
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
title="GitHub"
zIndex={toolbarButtonZIndex}
/>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { IconButton, useDisclosure } from "@chakra-ui/react";
import { LucideHeart } from "lucide-react";
import { toolbarButtonZIndex } from "./Toolbar";
import SupportThisSiteModal from "./SupportThisSiteModal";
export default function SupportThisSiteButton() {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<IconButton
as="a"
aria-label="Support"
icon={<LucideHeart />}
cursor="pointer"
onClick={onOpen}
size="md"
stroke="text.accent"
bg="transparent"
_hover={{
bg: "transparent",
svg: {
stroke: "accent.danger",
transition: "stroke 0.3s ease-in-out",
},
}}
title="Support"
variant="ghost"
zIndex={toolbarButtonZIndex}
sx={{
svg: {
stroke: "text.accent",
strokeWidth: "2px",
transition: "stroke 0.2s ease-in-out",
},
}}
/>
<SupportThisSiteModal isOpen={isOpen} onClose={onClose} />
</>
);
}

View File

@@ -0,0 +1,225 @@
import React from "react";
import {
Box,
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Text,
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";
const SupportThisSiteModal = observer(({ isOpen, onClose, zIndex }) => {
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",
duration: 3000,
isClosable: true,
});
}
};
const handleConfirmAmount = async () => {
try {
await clientTransactionStore.prepareTransaction();
toast({
title: "Success",
description: `Use your wallet app (Coinbase, ...ect) to send the selected asset to the provided address.`,
status: "success",
duration: 6000,
isClosable: true,
});
} catch (error) {
toast({
title: "Transaction Failed",
description: "There was an issue preparing your transaction.",
status: "error",
duration: 3000,
isClosable: true,
});
}
};
const donationMethods = [
{
name: "Ethereum",
icon: FaEthereum,
},
{
name: "Bitcoin",
icon: FaBitcoin,
},
{
name: "Dogecoin",
icon: DogecoinIcon,
},
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="md"
motionPreset="slideInBottom"
zIndex={zIndex}
>
<ModalOverlay />
<ModalContent bg="gray.800" color="text.primary">
<ModalHeader textAlign="center" mb={2}>
Support
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={8} align="center">
<Text fontSize="md" textAlign="center">
Your contributions are fuel for magic.
</Text>
<Tabs
align="center"
variant="soft-rounded"
colorScheme="teal"
isFitted
>
<TabList mb={2} w={"20%"}>
{donationMethods.map((method) => (
<Tab
p={4}
key={method.name}
onClick={() => {
clientTransactionStore.setSelectedMethod(method.name);
}}
>
<Box p={1} w={"fit-content"}>
<method.icon />{" "}
</Box>
{method.name}
</Tab>
))}
</TabList>
<TabPanels>
{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)
}
type="text"
bg="gray.700"
color="white"
w="100%"
/>
<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)
}
type="number"
bg="gray.700"
color="white"
w="100%"
/>
<Button
onClick={handleConfirmAmount}
size="md"
colorScheme="teal"
>
Confirm Amount
</Button>
</VStack>
) : (
<>
<Box
bg="white"
p={2}
borderRadius="lg"
mb={4}
w={"min-content"}
>
<QRCodeCanvas
value={
clientTransactionStore.depositAddress as string
}
size={180}
/>
</Box>
<Box
bg="gray.700"
p={4}
borderRadius="md"
wordBreak="unset"
w="100%"
textAlign="center"
mb={4}
>
<Text fontWeight="bold" fontSize="xs">
{clientTransactionStore.depositAddress}
</Text>
</Box>
<Button
onClick={handleCopy}
size="md"
colorScheme="teal"
mb={4}
>
{hasCopied ? "Address Copied!" : "Copy Address"}
</Button>
<Text fontSize="md" fontWeight="bold">
Transaction ID: {clientTransactionStore.txId}
</Text>
</>
)}
</TabPanel>
))}
</TabPanels>
</Tabs>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="outline" mr={3} onClick={onClose} colorScheme="gray">
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
});
export default SupportThisSiteModal;

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Flex } from "@chakra-ui/react";
import SupportThisSiteButton from "./SupportThisSiteButton";
import GithubButton from "./GithubButton";
import BuiltWithButton from "../BuiltWithButton";
const toolbarButtonZIndex = 901;
export { toolbarButtonZIndex };
function ToolBar({ isMobile }) {
return (
<Flex
direction={isMobile ? "row" : "column"}
alignItems={isMobile ? "flex-start" : "flex-end"}
pb={4}
>
<SupportThisSiteButton />
<GithubButton />
<BuiltWithButton />
</Flex>
);
}
export default ToolBar;