2
0

🦴 Add theme page backbone

This commit is contained in:
Baptiste Arnaud
2021-12-23 09:37:42 +01:00
parent 6ee0647384
commit 30ddb143b4
35 changed files with 784 additions and 87 deletions

View File

@ -161,3 +161,27 @@ export const TrashIcon = (props: IconProps) => (
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</Icon>
)
export const LayoutIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</Icon>
)
export const CodeIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</Icon>
)
export const PencilIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
<path d="M2 2l7.586 7.586"></path>
<circle cx="11" cy="11" r="2"></circle>
</Icon>
)

View File

@ -2,7 +2,6 @@ import { Flex } from '@chakra-ui/react'
import React from 'react'
import Graph from './graph/Graph'
import { DndContext } from 'contexts/DndContext'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
import { StepTypesList } from './StepTypesList'
import { PreviewDrawer } from './preview/PreviewDrawer'
import { RightPanel, useEditor } from 'contexts/EditorContext'
@ -10,13 +9,7 @@ import { RightPanel, useEditor } from 'contexts/EditorContext'
export const Board = () => {
const { rightPanel } = useEditor()
return (
<Flex
flex="1"
pos="relative"
bgColor="gray.50"
h={`calc(100vh - ${headerHeight}px)`}
marginTop={`${headerHeight}px`}
>
<Flex flex="1" pos="relative" bgColor="gray.50" h="full">
<DndContext>
<StepTypesList />
<Graph />

View File

@ -44,7 +44,6 @@ export const TextEditor = ({ initialValue, ids, onClose }: TextEditorProps) => {
}, [debouncedValue])
const save = (value: unknown[]) => {
console.log('SAVE', value)
if (value.length === 0) return
const html = serializeHtml(editor, {
nodes: value,

View File

@ -22,6 +22,7 @@ export const CreateBotButton = ({
onClick={handleClick}
paddingX={6}
whiteSpace={'normal'}
colorScheme="blue"
{...props}
>
<VStack spacing="6">

View File

@ -29,7 +29,7 @@ export const TypebotHeader = () => {
borderBottomWidth="1px"
justify="center"
align="center"
pos="fixed"
pos="relative"
h={`${headerHeight}px`}
zIndex={2}
bgColor="white"
@ -37,10 +37,7 @@ export const TypebotHeader = () => {
<HStack>
<Button
as={NextChakraLink}
href={{
pathname: `/typebots/${typebot?.id}/edit`,
query: { ...router.query, typebotId: [] },
}}
href={`/typebots/${typebot?.id}/edit`}
colorScheme={router.pathname.includes('/edit') ? 'blue' : 'gray'}
variant={router.pathname.includes('/edit') ? 'outline' : 'ghost'}
>
@ -48,21 +45,15 @@ export const TypebotHeader = () => {
</Button>
<Button
as={NextChakraLink}
href={{
pathname: `/typebots/${typebot?.id}/design`,
query: { ...router.query, typebotId: [] },
}}
colorScheme={router.pathname.endsWith('share') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('share') ? 'outline' : 'ghost'}
href={`/typebots/${typebot?.id}/theme`}
colorScheme={router.pathname.endsWith('theme') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('theme') ? 'outline' : 'ghost'}
>
Theme
</Button>
<Button
as={NextChakraLink}
href={{
pathname: `/typebots/${typebot?.id}/design`,
query: { ...router.query, typebotId: [] },
}}
href={`/typebots/${typebot?.id}/design`}
colorScheme={router.pathname.endsWith('share') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('share') ? 'outline' : 'ghost'}
>
@ -70,10 +61,7 @@ export const TypebotHeader = () => {
</Button>
<Button
as={NextChakraLink}
href={{
pathname: `/typebots/${typebot?.id}/share`,
query: { ...router.query, typebotId: [] },
}}
href={`/typebots/${typebot?.id}/share`}
colorScheme={router.pathname.endsWith('share') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('share') ? 'outline' : 'ghost'}
>
@ -81,10 +69,7 @@ export const TypebotHeader = () => {
</Button>
<Button
as={NextChakraLink}
href={{
pathname: `/typebots/${typebot?.id}/results/responses`,
query: { ...router.query, typebotId: [] },
}}
href={`/typebots/${typebot?.id}/results/responses`}
colorScheme={router.pathname.includes('results') ? 'blue' : 'gray'}
variant={router.pathname.includes('results') ? 'outline' : 'ghost'}
>

View File

@ -0,0 +1,38 @@
import { Flex, Text } from '@chakra-ui/react'
import { Background, BackgroundType } from 'bot-engine'
import React from 'react'
import { ColorPicker } from '../ColorPicker'
type BackgroundContentProps = {
background: Background
onBackgroundContentChange: (content: string) => void
}
export const BackgroundContent = ({
background,
onBackgroundContentChange,
}: BackgroundContentProps) => {
const handleContentChange = (content: string) =>
onBackgroundContentChange(content)
switch (background.type) {
case BackgroundType.COLOR:
return (
<Flex justify="space-between" align="center">
<Text>Background color:</Text>
<ColorPicker
initialColor={background.content}
onColorChange={handleContentChange}
/>
</Flex>
)
case BackgroundType.IMAGE:
return (
<Flex>
<Text>Image</Text>
</Flex>
)
default:
return <></>
}
}

View File

@ -0,0 +1,45 @@
import { Stack, Text } from '@chakra-ui/react'
import { Background, BackgroundType } from 'bot-engine'
import { deepEqual } from 'fast-equals'
import React, { useEffect, useState } from 'react'
import { BackgroundContent } from './BackgroundContent'
import { BackgroundTypeRadioButtons } from './BackgroundTypeRadioButtons'
type Props = {
initialBackground?: Background
onBackgroundChange: (newBackground: Background) => void
}
export const BackgroundSelector = ({
initialBackground,
onBackgroundChange,
}: Props) => {
const [currentBackground, setCurrentBackground] = useState<Background>(
initialBackground ?? { type: BackgroundType.NONE, content: '' }
)
useEffect(() => {
if (deepEqual(currentBackground, initialBackground)) return
onBackgroundChange(currentBackground)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentBackground])
const handleBackgroundTypeChange = (type: BackgroundType) =>
setCurrentBackground({ ...currentBackground, type })
const handleBackgroundContentChange = (content: string) =>
setCurrentBackground({ ...currentBackground, content })
return (
<Stack spacing={4}>
<Text>Background</Text>
<BackgroundTypeRadioButtons
backgroundType={currentBackground.type}
onBackgroundTypeChange={handleBackgroundTypeChange}
/>
<BackgroundContent
background={currentBackground}
onBackgroundContentChange={handleBackgroundContentChange}
/>
</Stack>
)
}

View File

@ -0,0 +1,73 @@
import {
Box,
Flex,
HStack,
useRadio,
useRadioGroup,
UseRadioProps,
} from '@chakra-ui/react'
import { BackgroundType } from 'bot-engine'
import { ReactNode } from 'react'
type Props = {
backgroundType: BackgroundType
onBackgroundTypeChange: (type: BackgroundType) => void
}
export const BackgroundTypeRadioButtons = ({
backgroundType,
onBackgroundTypeChange,
}: Props) => {
const options = ['Color', 'Image', 'None']
const { getRootProps, getRadioProps } = useRadioGroup({
name: 'background-type',
defaultValue: backgroundType,
onChange: (nextVal: string) =>
onBackgroundTypeChange(nextVal as BackgroundType),
})
const group = getRootProps()
return (
<HStack {...group}>
{options.map((value) => {
const radio = getRadioProps({ value })
return (
<RadioCard key={value} {...radio}>
{value}
</RadioCard>
)
})}
</HStack>
)
}
export const RadioCard = (props: UseRadioProps & { children: ReactNode }) => {
const { getInputProps, getCheckboxProps } = useRadio(props)
const input = getInputProps()
const checkbox = getCheckboxProps()
return (
<Box as="label" flex="1">
<input {...input} />
<Flex
{...checkbox}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
_checked={{
bg: 'orange.400',
color: 'white',
borderColor: 'orange.400',
}}
px={5}
py={2}
transition="background-color 150ms, color 150ms, border 150ms"
justifyContent="center"
>
{props.children}
</Flex>
</Box>
)
}

View File

@ -0,0 +1 @@
export { BackgroundSelector } from './BackgroundSelector'

View File

@ -0,0 +1,99 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverCloseButton,
PopoverHeader,
Center,
PopoverBody,
SimpleGrid,
Input,
Button,
} from '@chakra-ui/react'
import React, { useEffect, useState } from 'react'
const colorsSelection: `#${string}`[] = [
'#264653',
'#e9c46a',
'#2a9d8f',
'#7209b7',
'#023e8a',
'#ffe8d6',
'#d8f3dc',
'#4ea8de',
'#ffb4a2',
]
type Props = {
initialColor: string
onColorChange: (color: string) => void
}
export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
const [color, setColor] = useState(initialColor)
useEffect(() => {
onColorChange(color)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [color])
return (
<Popover variant="picker">
<PopoverTrigger>
<Button
aria-label={color}
background={color}
height="22px"
width="22px"
padding={0}
borderRadius={3}
borderWidth={1}
/>
</PopoverTrigger>
<PopoverContent width="170px">
<PopoverArrow bg={color} />
<PopoverCloseButton color="white" />
<PopoverHeader
height="100px"
backgroundColor={color}
borderTopLeftRadius={5}
borderTopRightRadius={5}
color={color === '#ffffff' ? 'gray.800' : 'white'}
>
<Center height="100%">{color}</Center>
</PopoverHeader>
<PopoverBody height="120px">
<SimpleGrid columns={5} spacing={2}>
{colorsSelection.map((c) => (
<Button
key={c}
aria-label={c}
background={c}
height="22px"
width="22px"
padding={0}
minWidth="unset"
borderRadius={3}
_hover={{ background: c }}
onClick={() => {
setColor(c)
}}
></Button>
))}
</SimpleGrid>
<Input
borderRadius={3}
marginTop={3}
placeholder="red.100"
size="sm"
value={color}
onChange={(e) => {
setColor(e.target.value)
}}
/>
</PopoverBody>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react'
import { Text, Flex } from '@chakra-ui/react'
import { SearchableDropdown } from './SearchableDropdown'
type FontSelectorProps = {
activeFont?: string
onSelectFont: (font: string) => void
}
export const FontSelector = ({
activeFont,
onSelectFont,
}: FontSelectorProps) => {
const [currentFont, setCurrentFont] = useState(activeFont)
const [googleFonts, setGoogleFonts] = useState<string[]>([])
useEffect(() => {
fetchPopularFonts().then(setGoogleFonts)
}, [])
const fetchPopularFonts = async () => {
const response = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=AIzaSyD2YAiipBLNYg058Wm-sPE-e2dPDn_zX8w&sort=popularity`
)
return (await response.json()).items.map(
(item: { family: string }) => item.family
)
}
return (
<Flex justify="space-between" align="center">
<Text>Font</Text>
<SearchableDropdown
selectedItem={activeFont}
items={googleFonts}
onSelectItem={(nextFont) => {
if (nextFont !== currentFont) {
setCurrentFont(nextFont)
onSelectFont(nextFont)
}
}}
/>
</Flex>
)
}

View File

@ -0,0 +1,111 @@
import {
useDisclosure,
useOutsideClick,
Flex,
Popover,
PopoverTrigger,
Input,
PopoverContent,
Button,
Text,
} from '@chakra-ui/react'
import { useState, useRef, useEffect, ChangeEvent } from 'react'
export const SearchableDropdown = ({
selectedItem,
items,
onSelectItem,
}: {
selectedItem?: string
items: string[]
onSelectItem: (value: string) => void
}) => {
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(selectedItem)
const [filteredItems, setFilteredItems] = useState([
...items
.filter((item) =>
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
)
.slice(0, 50),
])
const dropdownRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (filteredItems.length > 0) return
setFilteredItems([
...items
.filter((item) =>
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
)
.slice(0, 50),
])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
useOutsideClick({
ref: dropdownRef,
handler: onClose,
})
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
if (e.target.value === '') {
setFilteredItems([...items.slice(0, 50)])
return
}
setFilteredItems([
...items
.filter((item) =>
item.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
.slice(0, 50),
])
}
return (
<Flex ref={dropdownRef}>
<Popover isOpen={isOpen} initialFocusRef={inputRef}>
<PopoverTrigger>
<Input
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onClick={onOpen}
w="300px"
/>
</PopoverTrigger>
<PopoverContent maxH="35vh" overflowY="scroll" spacing="0" w="300px">
{filteredItems.length > 0 ? (
<>
{filteredItems.map((item, idx) => {
return (
<Button
minH="40px"
key={idx}
onClick={() => {
setInputValue(item)
onSelectItem(item)
onClose()
}}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="flex-start"
>
{item}
</Button>
)
})}
</>
) : (
<Text p={4}>Not found.</Text>
)}
</PopoverContent>
</Popover>
</Flex>
)
}

View File

@ -0,0 +1 @@
export { FontSelector } from './FontSelector'

View File

@ -0,0 +1,60 @@
import {
AccordionItem,
AccordionButton,
HStack,
Heading,
AccordionIcon,
AccordionPanel,
Stack,
} from '@chakra-ui/react'
import { PencilIcon } from 'assets/icons'
import { Background } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext'
import React from 'react'
import { BackgroundSelector } from './BackgroundSelector'
import { FontSelector } from './FontSelector'
export const GeneralContent = () => {
const { typebot, updateTheme } = useTypebot()
const handleSelectFont = (font: string) => {
if (!typebot) return
updateTheme({
...typebot.theme,
general: { ...typebot.theme.general, font },
})
}
const handleBackgroundChange = (background: Background) => {
if (!typebot) return
updateTheme({
...typebot.theme,
general: { ...typebot.theme.general, background },
})
}
return (
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<PencilIcon />
<Heading fontSize="lg" color="gray.700">
General
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
{typebot && (
<AccordionPanel as={Stack} pb={4} spacing={6}>
<FontSelector
activeFont={typebot.theme.general.font}
onSelectFont={handleSelectFont}
/>
<BackgroundSelector
initialBackground={typebot.theme.general.background}
onBackgroundChange={handleBackgroundChange}
/>
</AccordionPanel>
)}
</AccordionItem>
)
}

View File

@ -0,0 +1 @@
export { GeneralContent } from './GeneralContent'

View File

@ -0,0 +1,78 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Heading,
HStack,
Stack,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, LayoutIcon } from 'assets/icons'
import React from 'react'
import { GeneralContent } from './GeneralContent'
export const SideMenu = () => {
return (
<Stack flex="1" maxW="400px" borderRightWidth={1} pt={10} spacing={10}>
<Heading fontSize="xl" textAlign="center">
Customize the theme
</Heading>
<Accordion allowMultiple allowToggle>
<GeneralContent />
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<LayoutIcon />
<Heading fontSize="lg" color="gray.700">
Layout
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<ChatIcon />
<Heading fontSize="lg" color="gray.700">
Chat
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<CodeIcon />
<Heading fontSize="lg" color="gray.700">
Custom CSS
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@ -0,0 +1,22 @@
import { Flex } from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine'
import { useTypebot } from 'contexts/TypebotContext'
import React, { useMemo } from 'react'
import { parseTypebotToPublicTypebot } from 'services/typebots'
import { SideMenu } from './SideMenu'
export const ThemeContent = () => {
const { typebot } = useTypebot()
const publicTypebot = useMemo(
() => (typebot ? parseTypebotToPublicTypebot(typebot) : undefined),
[typebot]
)
return (
<Flex h="full" w="full">
<SideMenu />
<Flex flex="1">
{publicTypebot && <TypebotViewer typebot={publicTypebot} />}
</Flex>
</Flex>
)
}

View File

@ -1,5 +1,5 @@
import { useToast } from '@chakra-ui/react'
import { Block, Step, StepType, Target, Typebot } from 'bot-engine'
import { Block, Step, StepType, Target, Theme, Typebot } from 'bot-engine'
import { useRouter } from 'next/router'
import {
createContext,
@ -46,6 +46,7 @@ const typebotContext = createContext<{
target?: Target
}) => void
undo: () => void
updateTheme: (theme: Theme) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
@ -264,6 +265,11 @@ export const TypebotContext = ({
setLocalTypebot({ ...localTypebot, blocks })
}
const updateTheme = (theme: Theme) => {
if (!localTypebot) return
setLocalTypebot({ ...localTypebot, theme })
}
return (
<typebotContext.Provider
value={{
@ -279,6 +285,7 @@ export const TypebotContext = ({
save: saveTypebot,
removeBlock,
undo,
updateTheme,
}}
>
{children}

View File

@ -1,4 +1,4 @@
import { StartBlock, StepType } from 'bot-engine'
import { BackgroundType, StartBlock, StepType, Theme } from 'bot-engine'
import { Typebot, User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
@ -38,8 +38,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
},
],
}
const theme: Theme = {
general: {
font: 'Open Sans',
background: { type: BackgroundType.NONE, content: '#ffffff' },
},
}
const typebot = await prisma.typebot.create({
data: { ...data, ownerId: user.id, startBlock },
data: { ...data, ownerId: user.id, startBlock, theme },
})
return res.send(typebot)
}

View File

@ -20,7 +20,7 @@ const TypebotEditPage = () => {
<EditorContext>
<KBarProvider actions={actions}>
<KBar />
<Flex overflow="hidden" h="100vh">
<Flex overflow="hidden" h="100vh" flexDir="column">
<TypebotHeader />
<GraphProvider>
<Board />

View File

@ -0,0 +1,23 @@
import { Flex } from '@chakra-ui/layout'
import withAuth from 'components/HOC/withUser'
import { Seo } from 'components/Seo'
import { TypebotHeader } from 'components/shared/TypebotHeader'
import { ThemeContent } from 'components/theme/ThemeContent'
import { TypebotContext } from 'contexts/TypebotContext'
import { useRouter } from 'next/router'
import React from 'react'
const ThemePage = () => {
const { query } = useRouter()
return (
<TypebotContext typebotId={query.id?.toString()}>
<Seo title="Theme" />
<Flex overflow="hidden" h="100vh" flexDir="column">
<TypebotHeader />
<ThemeContent />
</Flex>
</TypebotContext>
)
}
export default withAuth(ThemePage)

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
import { Seo } from 'components/Seo'
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
import { createTypebot } from 'services/typebots'
import withAuth from 'components/HOC/withUser'
const TemplatesPage = () => {
const user = useUser()
@ -25,7 +26,7 @@ const TemplatesPage = () => {
folderId: router.query.folderId?.toString() ?? null,
})
if (error) toast({ description: error.message })
if (data) router.push(`/typebots/${data.id}`)
if (data) router.push(`/typebots/${data.id}/edit`)
setIsLoading(false)
}
@ -40,4 +41,4 @@ const TemplatesPage = () => {
)
}
export default TemplatesPage
export default withAuth(TemplatesPage)

View File

@ -155,4 +155,5 @@ export const parseTypebotToPublicTypebot = (
name: typebot.name,
startBlock: typebot.startBlock,
typebotId: typebot.id,
theme: typebot.theme,
})