(theme) Add theme templates

Allows you to save your themes and select a theme from Typebot's gallery

Closes #275
This commit is contained in:
Baptiste Arnaud
2023-03-28 15:10:06 +02:00
parent c1cf817127
commit 38ed5758fe
49 changed files with 2066 additions and 116 deletions

View File

@@ -9,7 +9,7 @@ type Props = {
export const CustomCssSettings = ({ customCss, onCustomCssChange }: Props) => {
return (
<CodeEditor
defaultValue={customCss ?? ''}
value={customCss ?? ''}
lang="css"
onChange={onCustomCssChange}
/>

View File

@@ -0,0 +1,69 @@
import { SaveIcon } from '@/components/icons'
import { trpc } from '@/lib/trpc'
import { Button, SimpleGrid, Stack, useDisclosure } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { areThemesEqual } from '../helpers/areThemesEqual'
import { SaveThemeModal } from './SaveThemeModal'
import { ThemeTemplateCard } from './ThemeTemplateCard'
type Props = {
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
workspaceId: string
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const MyTemplates = ({
selectedTemplateId,
currentTheme,
workspaceId,
onTemplateSelect,
}: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { data } = trpc.theme.listThemeTemplates.useQuery({
workspaceId,
})
const selectedTemplate = data?.themeTemplates.find(
(themeTemplate) => themeTemplate.id === selectedTemplateId
)
const closeModalAndSelectTemplate = (
template?: Pick<ThemeTemplate, 'id' | 'theme'>
) => {
if (template) onTemplateSelect(template)
onClose()
}
return (
<Stack spacing={4}>
{(!selectedTemplate ||
!areThemesEqual(selectedTemplate?.theme, currentTheme)) && (
<Button leftIcon={<SaveIcon />} onClick={onOpen} colorScheme="blue">
Save current theme
</Button>
)}
<SaveThemeModal
workspaceId={workspaceId}
selectedTemplate={selectedTemplate}
isOpen={isOpen}
onClose={closeModalAndSelectTemplate}
theme={currentTheme}
/>
<SimpleGrid columns={2} spacing={4}>
{data?.themeTemplates.map((themeTemplate) => (
<ThemeTemplateCard
key={themeTemplate.id}
workspaceId={workspaceId}
themeTemplate={themeTemplate}
isSelected={themeTemplate.id === selectedTemplateId}
onClick={() => onTemplateSelect(themeTemplate)}
onRenameClick={onOpen}
onDeleteSuccess={() => onTemplateSelect({ id: '' })}
/>
))}
</SimpleGrid>
</Stack>
)
}

View File

@@ -0,0 +1,109 @@
import { TextInput } from '@/components/inputs'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Button,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react'
import { createId } from '@paralleldrive/cuid2'
import { ThemeTemplate } from '@typebot.io/schemas'
import { FormEvent, useRef, useState } from 'react'
type Props = {
workspaceId: string
isOpen: boolean
onClose: (template?: Pick<ThemeTemplate, 'id' | 'theme'>) => void
selectedTemplate: Pick<ThemeTemplate, 'id' | 'name'> | undefined
theme: ThemeTemplate['theme']
}
export const SaveThemeModal = ({
workspaceId,
isOpen,
onClose,
selectedTemplate,
theme,
}: Props) => {
const { showToast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const {
theme: {
listThemeTemplates: { refetch: refetchThemeTemplates },
},
} = trpc.useContext()
const { mutate } = trpc.theme.saveThemeTemplate.useMutation({
onMutate: () => setIsSaving(true),
onSettled: () => setIsSaving(false),
onSuccess: ({ themeTemplate }) => {
refetchThemeTemplates()
onClose(themeTemplate)
},
onError: (error) => {
showToast({
description: error.message,
})
},
})
const updateExistingTemplate = (e: FormEvent) => {
e.preventDefault()
const newName = inputRef.current?.value
if (!newName) return
mutate({
name: newName,
theme,
workspaceId,
themeTemplateId: selectedTemplate?.id ?? createId(),
})
}
const saveNewTemplate = () => {
const newName = inputRef.current?.value
if (!newName) return
mutate({
name: newName,
theme,
workspaceId,
themeTemplateId: createId(),
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={inputRef}>
<ModalOverlay />
<ModalContent as="form" onSubmit={updateExistingTemplate}>
<ModalHeader>Save theme</ModalHeader>
<ModalCloseButton />
<ModalBody>
<TextInput
ref={inputRef}
label="Name:"
defaultValue={selectedTemplate?.name}
withVariableButton={false}
placeholder="My template"
isRequired
/>
</ModalBody>
<ModalFooter as={HStack}>
{selectedTemplate?.id && (
<Button isLoading={isSaving} onClick={saveNewTemplate}>
Save as new template
</Button>
)}
<Button type="submit" colorScheme="blue" isLoading={isSaving}>
{selectedTemplate?.id ? 'Update' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,33 @@
import { SimpleGrid, Stack } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { galleryTemplates } from '../galleryTemplates'
import { ThemeTemplateCard } from './ThemeTemplateCard'
type Props = {
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
workspaceId: string
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const TemplatesGallery = ({
selectedTemplateId,
workspaceId,
onTemplateSelect,
}: Props) => (
<Stack spacing={4}>
<SimpleGrid columns={2} spacing={4}>
{galleryTemplates.map((themeTemplate) => (
<ThemeTemplateCard
key={themeTemplate.id}
workspaceId={workspaceId}
themeTemplate={themeTemplate}
isSelected={themeTemplate.id === selectedTemplateId}
onClick={() => onTemplateSelect(themeTemplate)}
/>
))}
</SimpleGrid>
</Stack>
)

View File

@@ -7,28 +7,41 @@ import {
Heading,
HStack,
Stack,
Tag,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, PencilIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme } from '@typebot.io/schemas'
import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas'
import React from 'react'
import { CustomCssSettings } from './CustomCssSettings'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { headerHeight } from '@/features/editor/constants'
import { ChatThemeSettings } from './chat/ChatThemeSettings'
import { GeneralSettings } from './general/GeneralSettings'
import { ThemeTemplates } from './ThemeTemplates'
export const ThemeSideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const handleChatThemeChange = (chat: ChatTheme) =>
const updateChatTheme = (chat: ChatTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, chat } })
const handleGeneralThemeChange = (general: GeneralTheme) =>
const updateGeneralTheme = (general: GeneralTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, general } })
const handleCustomCssChange = (customCss: string) =>
const updateCustomCss = (customCss: string) =>
typebot && updateTypebot({ theme: { ...typebot.theme, customCss } })
const selectedTemplate = (
selectedTemplate: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => {
if (!typebot) return
const { theme, id } = selectedTemplate
updateTypebot({
selectedThemeTemplateId: id,
theme: theme ? { ...theme } : typebot.theme,
})
}
return (
<Stack
flex="1"
@@ -48,8 +61,33 @@ export const ThemeSideMenu = () => {
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<PencilIcon />
<Heading fontSize="lg">General</Heading>
<TableIcon />
<Heading fontSize="lg">
<HStack>
<span>Templates</span> <Tag colorScheme="orange">New!</Tag>
</HStack>
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4}>
{typebot && (
<ThemeTemplates
selectedTemplateId={
typebot.selectedThemeTemplateId ?? undefined
}
currentTheme={typebot.theme}
workspaceId={typebot.workspaceId}
onTemplateSelect={selectedTemplate}
/>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<DropletIcon />
<Heading fontSize="lg">Font & Background</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
@@ -57,7 +95,7 @@ export const ThemeSideMenu = () => {
{typebot && (
<GeneralSettings
generalTheme={typebot.theme.general}
onGeneralThemeChange={handleGeneralThemeChange}
onGeneralThemeChange={updateGeneralTheme}
/>
)}
</AccordionPanel>
@@ -75,7 +113,7 @@ export const ThemeSideMenu = () => {
<ChatThemeSettings
typebotId={typebot.id}
chatTheme={typebot.theme.chat}
onChatThemeChange={handleChatThemeChange}
onChatThemeChange={updateChatTheme}
/>
)}
</AccordionPanel>
@@ -92,7 +130,7 @@ export const ThemeSideMenu = () => {
{typebot && (
<CustomCssSettings
customCss={typebot.theme.customCss}
onCustomCssChange={handleCustomCssChange}
onCustomCssChange={updateCustomCss}
/>
)}
</AccordionPanel>

View File

@@ -0,0 +1,223 @@
import { MoreHorizontalIcon, EditIcon, TrashIcon } from '@/components/icons'
import { colors } from '@/lib/theme'
import { trpc } from '@/lib/trpc'
import {
Stack,
HStack,
Flex,
Menu,
MenuButton,
IconButton,
MenuList,
MenuItem,
Box,
Text,
Image,
} from '@chakra-ui/react'
import { BackgroundType, ThemeTemplate } from '@typebot.io/schemas'
import { useState } from 'react'
import { DefaultAvatar } from './DefaultAvatar'
export const ThemeTemplateCard = ({
workspaceId,
themeTemplate,
isSelected,
onClick,
onRenameClick,
onDeleteSuccess,
}: {
workspaceId: string
themeTemplate: Pick<ThemeTemplate, 'name' | 'theme' | 'id'>
isSelected: boolean
onRenameClick?: () => void
onClick: () => void
onDeleteSuccess?: () => void
}) => {
const [isDeleting, setIsDeleting] = useState(false)
const {
theme: {
listThemeTemplates: { refetch: refetchThemeTemplates },
},
} = trpc.useContext()
const { mutate } = trpc.theme.deleteThemeTemplate.useMutation({
onMutate: () => setIsDeleting(true),
onSettled: () => setIsDeleting(false),
onSuccess: () => {
refetchThemeTemplates()
if (onDeleteSuccess) onDeleteSuccess()
},
})
const deleteThemeTemplate = () => {
mutate({ themeTemplateId: themeTemplate.id, workspaceId })
}
const rounded =
themeTemplate.theme.chat.roundness === 'large'
? 'md'
: themeTemplate.theme.chat.roundness === 'none'
? 'none'
: 'sm'
return (
<Stack
cursor="pointer"
onClick={onClick}
spacing={0}
opacity={isDeleting ? 0.5 : 1}
pointerEvents={isDeleting ? 'none' : undefined}
rounded="md"
boxShadow={
isSelected
? `${colors['blue']['400']} 0 0 0 4px`
: `rgba(0, 0, 0, 0.08) 0px 2px 4px`
}
style={{
willChange: 'box-shadow',
transition: 'box-shadow 0.2s ease 0s',
}}
>
<Box
borderTopRadius="md"
backgroundSize="cover"
{...parseBackground(themeTemplate.theme.general.background)}
borderColor={isSelected ? 'blue.400' : undefined}
>
<HStack mt="4" ml="4" spacing={0.5} alignItems="flex-end">
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
/>
</HStack>
<HStack
mt="1"
mr="4"
ml="auto"
justifyContent="flex-end"
alignItems="flex-end"
>
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.guestBubbles.backgroundColor}
/>
<AvatarPreview avatar={themeTemplate.theme.chat.guestAvatar} />
</HStack>
<HStack mt="1" ml="4" spacing={0.5} alignItems="flex-end">
<AvatarPreview avatar={themeTemplate.theme.chat.hostAvatar} />
<Box
rounded="sm"
w="80px"
h="16px"
background={themeTemplate.theme.chat.hostBubbles.backgroundColor}
/>
</HStack>
<Flex
mt="1"
mb="4"
pr="4"
ml="auto"
w="full"
justifyContent="flex-end"
gap="1"
>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
<Box
rounded={rounded}
w="20px"
h="10px"
background={themeTemplate.theme.chat.buttons.backgroundColor}
/>
</Flex>
</Box>
<HStack p="2" justifyContent="space-between">
<Text fontSize="sm" noOfLines={1}>
{themeTemplate.name}
</Text>
{onDeleteSuccess && onRenameClick && (
<Menu isLazy>
<MenuButton
as={IconButton}
icon={<MoreHorizontalIcon />}
aria-label="Open template menu"
variant="ghost"
size="xs"
onClick={(e) => e.stopPropagation()}
/>
<MenuList onClick={(e) => e.stopPropagation()}>
{isSelected && (
<MenuItem icon={<EditIcon />} onClick={onRenameClick}>
Rename
</MenuItem>
)}
<MenuItem
icon={<TrashIcon />}
color="red.500"
onClick={deleteThemeTemplate}
>
Delete
</MenuItem>
</MenuList>
</Menu>
)}
</HStack>
</Stack>
)
}
const parseBackground = (background: {
type: BackgroundType
content?: string
}) => {
switch (background.type) {
case BackgroundType.COLOR:
return {
backgroundColor: background.content,
}
case BackgroundType.IMAGE:
return { backgroundImage: `url(${background.content})` }
case BackgroundType.NONE:
return
}
}
const AvatarPreview = ({
avatar,
}: {
avatar:
| {
isEnabled: boolean
url?: string | undefined
}
| undefined
}) => {
if (!avatar?.isEnabled) return null
return avatar.url ? (
<Image
src={avatar.url}
alt="Avatar preview in theme template card"
boxSize="12px"
rounded="full"
/>
) : (
<DefaultAvatar boxSize="12px" />
)
}

View File

@@ -0,0 +1,86 @@
import { Button, HStack, Stack } from '@chakra-ui/react'
import { ThemeTemplate } from '@typebot.io/schemas'
import { useState } from 'react'
import { MyTemplates } from './MyTemplates'
import { TemplatesGallery } from './TemplatesGallery'
type Tab = 'my-templates' | 'gallery'
type Props = {
workspaceId: string
selectedTemplateId: string | undefined
currentTheme: ThemeTemplate['theme']
onTemplateSelect: (
template: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
) => void
}
export const ThemeTemplates = ({
workspaceId,
selectedTemplateId,
currentTheme,
onTemplateSelect,
}: Props) => {
const [selectedTab, setSelectedTab] = useState<Tab>('my-templates')
return (
<Stack spacing={4} pb={12}>
<HStack>
<Button
flex="1"
variant="outline"
colorScheme={selectedTab === 'my-templates' ? 'blue' : 'gray'}
onClick={() => setSelectedTab('my-templates')}
>
My templates
</Button>
<Button
flex="1"
variant="outline"
colorScheme={selectedTab === 'gallery' ? 'blue' : 'gray'}
onClick={() => setSelectedTab('gallery')}
>
Gallery
</Button>
</HStack>
<ThemeTemplatesBody
tab={selectedTab}
currentTheme={currentTheme}
workspaceId={workspaceId}
selectedTemplateId={selectedTemplateId}
onTemplateSelect={onTemplateSelect}
/>
</Stack>
)
}
const ThemeTemplatesBody = ({
tab,
workspaceId,
selectedTemplateId,
currentTheme,
onTemplateSelect,
}: {
tab: Tab
} & Props) => {
switch (tab) {
case 'my-templates':
return (
<MyTemplates
onTemplateSelect={onTemplateSelect}
currentTheme={currentTheme}
selectedTemplateId={selectedTemplateId}
workspaceId={workspaceId}
/>
)
case 'gallery':
return (
<TemplatesGallery
onTemplateSelect={onTemplateSelect}
currentTheme={currentTheme}
selectedTemplateId={selectedTemplateId}
workspaceId={workspaceId}
/>
)
}
}

View File

@@ -19,16 +19,13 @@ export const ButtonsTheme = ({ buttons, onButtonsChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={buttons.backgroundColor}
value={buttons.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={buttons.color}
onColorChange={handleTextChange}
/>
<ColorPicker value={buttons.color} onColorChange={handleTextChange} />
</Flex>
</Stack>
)

View File

@@ -65,29 +65,7 @@ export const ChatThemeSettings = ({
onHostBubblesChange={handleHostBubblesChange}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">Corners roundness</Heading>
<RadioButtons
options={[
{
label: <NoRadiusIcon />,
value: 'none',
},
{
label: <MediumRadiusIcon />,
value: 'medium',
},
{
label: <LargeRadiusIcon />,
value: 'large',
},
]}
defaultValue={chatTheme.roundness ?? 'medium'}
onSelect={(roundness) =>
onChatThemeChange({ ...chatTheme, roundness })
}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">User bubbles</Heading>
<GuestBubbles
@@ -109,6 +87,29 @@ export const ChatThemeSettings = ({
onInputsChange={handleInputsChange}
/>
</Stack>
<Stack borderWidth={1} rounded="md" p="4" spacing={4}>
<Heading fontSize="lg">Corners roundness</Heading>
<RadioButtons
options={[
{
label: <NoRadiusIcon />,
value: 'none',
},
{
label: <MediumRadiusIcon />,
value: 'medium',
},
{
label: <LargeRadiusIcon />,
value: 'large',
},
]}
value={chatTheme.roundness ?? 'medium'}
onSelect={(roundness) =>
onChatThemeChange({ ...chatTheme, roundness })
}
/>
</Stack>
</Stack>
)
}

View File

@@ -19,14 +19,14 @@ export const GuestBubbles = ({ guestBubbles, onGuestBubblesChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={guestBubbles.backgroundColor}
value={guestBubbles.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={guestBubbles.color}
value={guestBubbles.color}
onColorChange={handleTextChange}
/>
</Flex>

View File

@@ -19,14 +19,14 @@ export const HostBubbles = ({ hostBubbles, onHostBubblesChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={hostBubbles.backgroundColor}
value={hostBubbles.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={hostBubbles.color}
value={hostBubbles.color}
onColorChange={handleTextChange}
/>
</Flex>

View File

@@ -21,21 +21,18 @@ export const InputsTheme = ({ inputs, onInputsChange }: Props) => {
<Flex justify="space-between" align="center">
<Text>Background:</Text>
<ColorPicker
initialColor={inputs.backgroundColor}
value={inputs.backgroundColor}
onColorChange={handleBackgroundChange}
/>
</Flex>
<Flex justify="space-between" align="center">
<Text>Text:</Text>
<ColorPicker
initialColor={inputs.color}
onColorChange={handleTextChange}
/>
<ColorPicker value={inputs.color} onColorChange={handleTextChange} />
</Flex>
<Flex justify="space-between" align="center">
<Text>Placeholder text:</Text>
<ColorPicker
initialColor={inputs.placeholderColor}
value={inputs.placeholderColor}
onColorChange={handlePlaceholderChange}
/>
</Flex>

View File

@@ -35,7 +35,7 @@ export const BackgroundContent = ({
<Flex justify="space-between" align="center">
<Text>Background color:</Text>
<ColorPicker
initialColor={background.content ?? defaultBackgroundColor}
value={background.content ?? defaultBackgroundColor}
onColorChange={handleContentChange}
/>
</Flex>

View File

@@ -31,7 +31,7 @@ export const BackgroundSelector = ({
BackgroundType.IMAGE,
BackgroundType.NONE,
]}
defaultValue={background?.type ?? defaultBackgroundType}
value={background?.type ?? defaultBackgroundType}
onSelect={handleBackgroundTypeChange}
/>
<BackgroundContent

View File

@@ -41,7 +41,7 @@ export const FontSelector = ({
<HStack justify="space-between" align="center">
<Text>Font</Text>
<AutocompleteInput
defaultValue={activeFont}
value={activeFont}
items={googleFonts}
onChange={handleFontSelected}
withVariableButton={false}