@ -68,7 +68,6 @@
|
||||
"libphonenumber-js": "1.10.37",
|
||||
"micro": "10.0.1",
|
||||
"micro-cors": "0.1.1",
|
||||
"minio": "7.1.1",
|
||||
"next": "13.4.3",
|
||||
"next-auth": "4.22.1",
|
||||
"next-international": "0.9.5",
|
||||
@ -106,7 +105,6 @@
|
||||
"@types/canvas-confetti": "1.6.0",
|
||||
"@types/jsonwebtoken": "9.0.2",
|
||||
"@types/micro-cors": "0.1.3",
|
||||
"@types/minio": "7.1.1",
|
||||
"@types/node": "20.4.2",
|
||||
"@types/nodemailer": "6.4.8",
|
||||
"@types/nprogress": "0.2.0",
|
||||
|
@ -10,16 +10,17 @@ import {
|
||||
import React from 'react'
|
||||
import { EmojiOrImageIcon } from './EmojiOrImageIcon'
|
||||
import { ImageUploadContent } from './ImageUploadContent'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
|
||||
type Props = {
|
||||
uploadFilePath: string
|
||||
uploadFileProps: FilePathUploadProps
|
||||
icon?: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
boxSize?: string
|
||||
}
|
||||
|
||||
export const EditableEmojiOrImageIcon = ({
|
||||
uploadFilePath,
|
||||
uploadFileProps,
|
||||
icon,
|
||||
onChangeIcon,
|
||||
boxSize,
|
||||
@ -54,7 +55,7 @@ export const EditableEmojiOrImageIcon = ({
|
||||
</Tooltip>
|
||||
<PopoverContent p="2">
|
||||
<ImageUploadContent
|
||||
filePath={uploadFilePath}
|
||||
uploadFileProps={uploadFileProps}
|
||||
defaultUrl={icon ?? ''}
|
||||
onSubmit={onChangeIcon}
|
||||
excludedTabs={['giphy', 'unsplash']}
|
||||
|
@ -6,12 +6,12 @@ import { TextInput } from '../inputs/TextInput'
|
||||
import { EmojiSearchableList } from './emoji/EmojiSearchableList'
|
||||
import { UnsplashPicker } from './UnsplashPicker'
|
||||
import { IconPicker } from './IconPicker'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
|
||||
type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash' | 'icon'
|
||||
|
||||
type Props = {
|
||||
filePath: string | undefined
|
||||
includeFileName?: boolean
|
||||
uploadFileProps: FilePathUploadProps | undefined
|
||||
defaultUrl?: string
|
||||
imageSize?: 'small' | 'regular' | 'thumb'
|
||||
initialTab?: Tabs
|
||||
@ -36,8 +36,7 @@ const defaultDisplayedTabs: Tabs[] = [
|
||||
]
|
||||
|
||||
export const ImageUploadContent = ({
|
||||
filePath,
|
||||
includeFileName,
|
||||
uploadFileProps,
|
||||
defaultUrl,
|
||||
onSubmit,
|
||||
imageSize = 'regular',
|
||||
@ -123,8 +122,7 @@ export const ImageUploadContent = ({
|
||||
</HStack>
|
||||
|
||||
<BodyContent
|
||||
filePath={filePath}
|
||||
includeFileName={includeFileName}
|
||||
uploadFileProps={uploadFileProps}
|
||||
tab={currentTab}
|
||||
imageSize={imageSize}
|
||||
onSubmit={handleSubmit}
|
||||
@ -135,15 +133,13 @@ export const ImageUploadContent = ({
|
||||
}
|
||||
|
||||
const BodyContent = ({
|
||||
includeFileName,
|
||||
filePath,
|
||||
uploadFileProps,
|
||||
tab,
|
||||
defaultUrl,
|
||||
imageSize,
|
||||
onSubmit,
|
||||
}: {
|
||||
includeFileName?: boolean
|
||||
filePath: string | undefined
|
||||
uploadFileProps?: FilePathUploadProps
|
||||
tab: Tabs
|
||||
defaultUrl?: string
|
||||
imageSize: 'small' | 'regular' | 'thumb'
|
||||
@ -151,11 +147,10 @@ const BodyContent = ({
|
||||
}) => {
|
||||
switch (tab) {
|
||||
case 'upload': {
|
||||
if (!filePath) return null
|
||||
if (!uploadFileProps) return null
|
||||
return (
|
||||
<UploadFileContent
|
||||
filePath={filePath}
|
||||
includeFileName={includeFileName}
|
||||
uploadFileProps={uploadFileProps}
|
||||
onNewUrl={onSubmit}
|
||||
/>
|
||||
)
|
||||
@ -176,16 +171,14 @@ const BodyContent = ({
|
||||
type ContentProps = { onNewUrl: (url: string) => void }
|
||||
|
||||
const UploadFileContent = ({
|
||||
filePath,
|
||||
includeFileName,
|
||||
uploadFileProps,
|
||||
onNewUrl,
|
||||
}: ContentProps & { filePath: string; includeFileName?: boolean }) => (
|
||||
}: ContentProps & { uploadFileProps: FilePathUploadProps }) => (
|
||||
<Flex justify="center" py="2">
|
||||
<UploadButton
|
||||
fileType="image"
|
||||
filePath={filePath}
|
||||
filePathProps={uploadFileProps}
|
||||
onFileUploaded={onNewUrl}
|
||||
includeFileName={includeFileName}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Choose an image
|
||||
|
@ -1,25 +1,44 @@
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { compressFile } from '@/helpers/compressFile'
|
||||
|
||||
type UploadButtonProps = {
|
||||
fileType: 'image' | 'audio'
|
||||
filePath: string
|
||||
includeFileName?: boolean
|
||||
filePathProps: FilePathUploadProps
|
||||
onFileUploaded: (url: string) => void
|
||||
} & ButtonProps
|
||||
|
||||
export const UploadButton = ({
|
||||
fileType,
|
||||
filePath,
|
||||
includeFileName,
|
||||
filePathProps,
|
||||
onFileUploaded,
|
||||
...props
|
||||
}: UploadButtonProps) => {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { showToast } = useToast()
|
||||
const [file, setFile] = useState<File>()
|
||||
|
||||
const { mutate } = trpc.generateUploadUrl.useMutation({
|
||||
onSettled: () => {
|
||||
setIsUploading(false)
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
const upload = await fetch(data.presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!upload.ok) {
|
||||
showToast({ description: 'Error while trying to upload the file.' })
|
||||
return
|
||||
}
|
||||
|
||||
onFileUploaded(data.fileUrl + '?v=' + Date.now())
|
||||
},
|
||||
})
|
||||
|
||||
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target?.files) return
|
||||
@ -27,16 +46,11 @@ export const UploadButton = ({
|
||||
const file = e.target.files[0] as File | undefined
|
||||
if (!file)
|
||||
return showToast({ description: 'Could not read file.', status: 'error' })
|
||||
const urls = await uploadFiles({
|
||||
files: [
|
||||
{
|
||||
file: await compressFile(file),
|
||||
path: `public/${filePath}${includeFileName ? `/${file.name}` : ''}`,
|
||||
},
|
||||
],
|
||||
setFile(await compressFile(file))
|
||||
mutate({
|
||||
filePathProps,
|
||||
fileType: file.type,
|
||||
})
|
||||
if (urls.length && urls[0]) onFileUploaded(urls[0] + '?v=' + Date.now())
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -36,15 +36,20 @@ export const MyAccountForm = () => {
|
||||
name={user?.name ?? undefined}
|
||||
/>
|
||||
<Stack>
|
||||
<UploadButton
|
||||
size="sm"
|
||||
fileType="image"
|
||||
filePath={`users/${user?.id}/avatar`}
|
||||
leftIcon={<UploadIcon />}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
>
|
||||
{scopedT('changePhotoButton.label')}
|
||||
</UploadButton>
|
||||
{user?.id && (
|
||||
<UploadButton
|
||||
size="sm"
|
||||
fileType="image"
|
||||
filePathProps={{
|
||||
userId: user.id,
|
||||
fileName: 'avatar',
|
||||
}}
|
||||
leftIcon={<UploadIcon />}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
>
|
||||
{scopedT('changePhotoButton.label')}
|
||||
</UploadButton>
|
||||
)}
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
{scopedT('changePhotoButton.specification')}
|
||||
</Text>
|
||||
|
@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
|
||||
import { BubbleBlockType, defaultAudioBubbleContent } from '@typebot.io/schemas'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
|
||||
|
||||
const audioSampleUrl =
|
||||
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
@ -30,11 +31,17 @@ test('should work as expected', async ({ page }) => {
|
||||
await page.setInputFiles('input[type="file"]', getTestAsset('sample.mp3'))
|
||||
await expect(page.locator('audio')).toHaveAttribute(
|
||||
'src',
|
||||
RegExp(`/public/typebots/${typebotId}/blocks`, 'gm')
|
||||
RegExp(
|
||||
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks`,
|
||||
'gm'
|
||||
)
|
||||
)
|
||||
await page.getByRole('button', { name: 'Preview', exact: true }).click()
|
||||
await expect(page.locator('audio')).toHaveAttribute(
|
||||
'src',
|
||||
RegExp(`/public/typebots/${typebotId}/blocks`, 'gm')
|
||||
RegExp(
|
||||
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks`,
|
||||
'gm'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
@ -5,15 +5,16 @@ import { useState } from 'react'
|
||||
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
|
||||
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
|
||||
type Props = {
|
||||
fileUploadPath: string
|
||||
uploadFileProps: FilePathUploadProps
|
||||
content: AudioBubbleContent
|
||||
onContentChange: (content: AudioBubbleContent) => void
|
||||
}
|
||||
|
||||
export const AudioBubbleForm = ({
|
||||
fileUploadPath,
|
||||
uploadFileProps,
|
||||
content,
|
||||
onContentChange,
|
||||
}: Props) => {
|
||||
@ -49,7 +50,7 @@ export const AudioBubbleForm = ({
|
||||
<Flex justify="center" py="2">
|
||||
<UploadButton
|
||||
fileType="audio"
|
||||
filePath={fileUploadPath}
|
||||
filePathProps={uploadFileProps}
|
||||
onFileUploaded={updateUrl}
|
||||
colorScheme="blue"
|
||||
>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ImageUploadContent } from '@/components/ImageUploadContent'
|
||||
import { TextInput } from '@/components/inputs'
|
||||
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { isDefined, isNotEmpty } from '@typebot.io/lib'
|
||||
@ -8,13 +9,13 @@ import { ImageBubbleBlock } from '@typebot.io/schemas'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
uploadFileProps: FilePathUploadProps
|
||||
block: ImageBubbleBlock
|
||||
onContentChange: (content: ImageBubbleBlock['content']) => void
|
||||
}
|
||||
|
||||
export const ImageBubbleSettings = ({
|
||||
typebotId,
|
||||
uploadFileProps,
|
||||
block,
|
||||
onContentChange,
|
||||
}: Props) => {
|
||||
@ -53,7 +54,7 @@ export const ImageBubbleSettings = ({
|
||||
return (
|
||||
<Stack p="2" spacing={4}>
|
||||
<ImageUploadContent
|
||||
filePath={`typebots/${typebotId}/blocks/${block.id}`}
|
||||
uploadFileProps={uploadFileProps}
|
||||
defaultUrl={block.content?.url}
|
||||
onSubmit={updateImage}
|
||||
/>
|
||||
|
@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
|
||||
import { BubbleBlockType, defaultImageBubbleContent } from '@typebot.io/schemas'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
|
||||
|
||||
const unsplashImageSrc =
|
||||
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
|
||||
@ -29,7 +30,10 @@ test.describe.parallel('Image bubble block', () => {
|
||||
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
|
||||
await expect(page.locator('img')).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(`/public/typebots/${typebotId}/blocks/block2`, 'gm')
|
||||
new RegExp(
|
||||
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks/block2`,
|
||||
'gm'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -134,6 +134,7 @@ export const PictureChoiceItemNode = ({
|
||||
>
|
||||
{typebot && (
|
||||
<PictureChoiceItemSettings
|
||||
workspaceId={typebot.workspaceId}
|
||||
typebotId={typebot.id}
|
||||
item={item}
|
||||
blockId={
|
||||
|
@ -16,6 +16,7 @@ import { ConditionForm } from '@/features/blocks/logic/condition/components/Cond
|
||||
import { Condition, LogicalOperator } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
blockId: string
|
||||
item: PictureChoiceItem
|
||||
@ -23,6 +24,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const PictureChoiceItemSettings = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
blockId,
|
||||
item,
|
||||
@ -69,7 +71,12 @@ export const PictureChoiceItemSettings = ({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p="4" w="500px">
|
||||
<ImageUploadContent
|
||||
filePath={`typebots/${typebotId}/blocks/${blockId}/items/${item.id}`}
|
||||
uploadFileProps={{
|
||||
workspaceId,
|
||||
typebotId,
|
||||
blockId,
|
||||
itemId: item.id,
|
||||
}}
|
||||
defaultUrl={item.pictureSrc}
|
||||
onSubmit={(url) => {
|
||||
updateImage(url)
|
||||
|
@ -7,10 +7,10 @@ import { openAICredentialsSchema } from '@typebot.io/schemas/features/blocks/int
|
||||
import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail'
|
||||
import { encrypt } from '@typebot.io/lib/api/encryption'
|
||||
import { z } from 'zod'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { Credentials } from '@typebot.io/schemas'
|
||||
import { isDefined } from '@typebot.io/lib/utils'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
|
||||
const inputShape = {
|
||||
data: true,
|
||||
|
@ -2,7 +2,7 @@ import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
|
||||
export const deleteCredentials = authenticatedProcedure
|
||||
.meta({
|
||||
|
@ -3,9 +3,9 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import got, { HTTPError } from 'got'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
|
||||
export const createCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
|
@ -2,9 +2,9 @@ import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import got from 'got'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
|
||||
export const deleteCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
|
@ -179,7 +179,11 @@ export const TypebotHeader = () => {
|
||||
<HStack spacing={1}>
|
||||
{typebot && (
|
||||
<EditableEmojiOrImageIcon
|
||||
uploadFilePath={`typebots/${typebot.id}/icon`}
|
||||
uploadFileProps={{
|
||||
workspaceId: typebot.workspaceId,
|
||||
typebotId: typebot.id,
|
||||
fileName: 'icon',
|
||||
}}
|
||||
icon={typebot?.icon}
|
||||
onChangeIcon={handleChangeIcon}
|
||||
/>
|
||||
|
@ -284,7 +284,11 @@ export const BlockNode = ({
|
||||
)}
|
||||
{typebot && isMediaBubbleBlock(block) && (
|
||||
<MediaBubblePopoverContent
|
||||
typebotId={typebot.id}
|
||||
uploadFileProps={{
|
||||
workspaceId: typebot.workspaceId,
|
||||
typebotId: typebot.id,
|
||||
blockId: block.id,
|
||||
}}
|
||||
block={block}
|
||||
onContentChange={handleContentChange}
|
||||
/>
|
||||
|
@ -2,6 +2,7 @@ import { AudioBubbleForm } from '@/features/blocks/bubbles/audio/components/Audi
|
||||
import { EmbedUploadContent } from '@/features/blocks/bubbles/embed/components/EmbedUploadContent'
|
||||
import { ImageBubbleSettings } from '@/features/blocks/bubbles/image/components/ImageBubbleSettings'
|
||||
import { VideoUploadContent } from '@/features/blocks/bubbles/video/components/VideoUploadContent'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
import {
|
||||
Portal,
|
||||
PopoverContent,
|
||||
@ -17,7 +18,7 @@ import {
|
||||
import { useRef } from 'react'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
uploadFileProps: FilePathUploadProps
|
||||
block: Exclude<BubbleBlock, TextBubbleBlock>
|
||||
onContentChange: (content: BubbleBlockContent) => void
|
||||
}
|
||||
@ -42,7 +43,7 @@ export const MediaBubblePopoverContent = (props: Props) => {
|
||||
}
|
||||
|
||||
export const MediaBubbleContent = ({
|
||||
typebotId,
|
||||
uploadFileProps,
|
||||
block,
|
||||
onContentChange,
|
||||
}: Props) => {
|
||||
@ -50,7 +51,7 @@ export const MediaBubbleContent = ({
|
||||
case BubbleBlockType.IMAGE: {
|
||||
return (
|
||||
<ImageBubbleSettings
|
||||
typebotId={typebotId}
|
||||
uploadFileProps={uploadFileProps}
|
||||
block={block}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
@ -76,7 +77,7 @@ export const MediaBubbleContent = ({
|
||||
return (
|
||||
<AudioBubbleForm
|
||||
content={block.content}
|
||||
fileUploadPath={`typebots/${typebotId}/blocks/${block.id}`}
|
||||
uploadFileProps={uploadFileProps}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
)
|
||||
|
@ -81,7 +81,7 @@ export const ButtonThemeSettings = ({ buttonTheme, onChange }: Props) => {
|
||||
updateCustomIconSrc(url)
|
||||
onClose()
|
||||
}}
|
||||
filePath={undefined}
|
||||
uploadFileProps={undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</>
|
||||
|
@ -16,6 +16,7 @@ import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { TextInput, Textarea } from '@/components/inputs'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
typebotName: string
|
||||
metadata: Metadata
|
||||
@ -23,6 +24,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const MetadataForm = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
typebotName,
|
||||
metadata,
|
||||
@ -61,7 +63,11 @@ export const MetadataForm = ({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p="4" w="400px">
|
||||
<ImageUploadContent
|
||||
filePath={`typebots/${typebotId}/favIcon`}
|
||||
uploadFileProps={{
|
||||
workspaceId,
|
||||
typebotId,
|
||||
fileName: 'favIcon',
|
||||
}}
|
||||
defaultUrl={metadata.favIconUrl ?? ''}
|
||||
onSubmit={handleFavIconSubmit}
|
||||
excludedTabs={['giphy', 'unsplash', 'emoji']}
|
||||
@ -87,7 +93,11 @@ export const MetadataForm = ({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent p="4" w="500px">
|
||||
<ImageUploadContent
|
||||
filePath={`typebots/${typebotId}/ogImage`}
|
||||
uploadFileProps={{
|
||||
workspaceId,
|
||||
typebotId,
|
||||
fileName: 'ogImage',
|
||||
}}
|
||||
defaultUrl={metadata.imageUrl}
|
||||
onSubmit={handleImageSubmit}
|
||||
excludedTabs={['giphy', 'icon', 'emoji']}
|
||||
|
@ -94,6 +94,7 @@ export const SettingsSideMenu = () => {
|
||||
<AccordionPanel pb={4} px="6">
|
||||
{typebot && (
|
||||
<MetadataForm
|
||||
workspaceId={typebot.workspaceId}
|
||||
typebotId={typebot.id}
|
||||
typebotName={typebot.name}
|
||||
metadata={typebot.settings.metadata}
|
||||
|
@ -125,6 +125,7 @@ export const ThemeSideMenu = () => {
|
||||
<AccordionPanel pb={4}>
|
||||
{typebot && (
|
||||
<ChatThemeSettings
|
||||
workspaceId={typebot.workspaceId}
|
||||
typebotId={typebot.id}
|
||||
chatTheme={typebot.theme.chat}
|
||||
onChatThemeChange={updateChatTheme}
|
||||
|
@ -17,9 +17,10 @@ import {
|
||||
import { ImageUploadContent } from '@/components/ImageUploadContent'
|
||||
import { DefaultAvatar } from '../DefaultAvatar'
|
||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
||||
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
|
||||
|
||||
type Props = {
|
||||
uploadFilePath: string
|
||||
uploadFileProps: FilePathUploadProps
|
||||
title: string
|
||||
avatarProps?: AvatarProps
|
||||
isDefaultCheck?: boolean
|
||||
@ -27,7 +28,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const AvatarForm = ({
|
||||
uploadFilePath,
|
||||
uploadFileProps,
|
||||
title,
|
||||
avatarProps,
|
||||
isDefaultCheck = false,
|
||||
@ -90,7 +91,7 @@ export const AvatarForm = ({
|
||||
w="500px"
|
||||
>
|
||||
<ImageUploadContent
|
||||
filePath={uploadFilePath}
|
||||
uploadFileProps={uploadFileProps}
|
||||
defaultUrl={avatarProps?.url}
|
||||
imageSize="thumb"
|
||||
onSubmit={handleImageUrl}
|
||||
|
@ -19,12 +19,14 @@ import { HostBubbles } from './HostBubbles'
|
||||
import { InputsTheme } from './InputsTheme'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
chatTheme: ChatTheme
|
||||
onChatThemeChange: (chatTheme: ChatTheme) => void
|
||||
}
|
||||
|
||||
export const ChatThemeSettings = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
chatTheme,
|
||||
onChatThemeChange,
|
||||
@ -46,14 +48,22 @@ export const ChatThemeSettings = ({
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<AvatarForm
|
||||
uploadFilePath={`typebots/${typebotId}/hostAvatar`}
|
||||
uploadFileProps={{
|
||||
workspaceId,
|
||||
typebotId,
|
||||
fileName: 'hostAvatar',
|
||||
}}
|
||||
title="Bot avatar"
|
||||
avatarProps={chatTheme.hostAvatar}
|
||||
isDefaultCheck
|
||||
onAvatarChange={handleHostAvatarChange}
|
||||
/>
|
||||
<AvatarForm
|
||||
uploadFilePath={`typebots/${typebotId}/guestAvatar`}
|
||||
uploadFileProps={{
|
||||
workspaceId,
|
||||
typebotId,
|
||||
fileName: 'guestAvatar',
|
||||
}}
|
||||
title="User avatar"
|
||||
avatarProps={chatTheme.guestAvatar}
|
||||
onAvatarChange={handleGuestAvatarChange}
|
||||
|
@ -42,6 +42,7 @@ export const BackgroundContent = ({
|
||||
</Flex>
|
||||
)
|
||||
case BackgroundType.IMAGE:
|
||||
if (!typebot) return null
|
||||
return (
|
||||
<Popover isLazy placement="top">
|
||||
<PopoverTrigger>
|
||||
@ -63,7 +64,11 @@ export const BackgroundContent = ({
|
||||
<Portal>
|
||||
<PopoverContent p="4" w="500px">
|
||||
<ImageUploadContent
|
||||
filePath={`typebots/${typebot?.id}/background`}
|
||||
uploadFileProps={{
|
||||
workspaceId: typebot.workspaceId,
|
||||
typebotId: typebot.id,
|
||||
fileName: 'background',
|
||||
}}
|
||||
defaultUrl={background.content}
|
||||
onSubmit={handleContentChange}
|
||||
excludedTabs={['giphy', 'icon']}
|
||||
@ -72,7 +77,5 @@ export const BackgroundContent = ({
|
||||
</Portal>
|
||||
</Popover>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export const isWriteTypebotForbidden = async (
|
||||
typebot: Pick<Typebot, 'workspaceId'> & {
|
||||
collaborators: Pick<CollaboratorsOnTypebots, 'userId' | 'type'>[]
|
||||
},
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
user: Pick<User, 'id'>
|
||||
) => {
|
||||
if (
|
||||
typebot.collaborators.find(
|
||||
|
173
apps/builder/src/features/upload/api/generateUploadUrl.ts
Normal file
173
apps/builder/src/features/upload/api/generateUploadUrl.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
|
||||
|
||||
const inputSchema = z.object({
|
||||
filePathProps: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
typebotId: z.string(),
|
||||
blockId: z.string(),
|
||||
itemId: z.string().optional(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
typebotId: z.string(),
|
||||
fileName: z.string(),
|
||||
})
|
||||
)
|
||||
.or(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
fileName: z.string(),
|
||||
})
|
||||
)
|
||||
.or(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
fileName: z.string(),
|
||||
})
|
||||
),
|
||||
fileType: z.string().optional(),
|
||||
})
|
||||
|
||||
export type FilePathUploadProps = z.infer<
|
||||
typeof inputSchema.shape.filePathProps
|
||||
>
|
||||
|
||||
export const generateUploadUrl = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/generate-upload-url',
|
||||
summary: 'Generate upload URL',
|
||||
description: 'Generate the needed URL to upload a file from the client',
|
||||
},
|
||||
})
|
||||
.input(inputSchema)
|
||||
.output(
|
||||
z.object({
|
||||
presignedUrl: z.string(),
|
||||
fileUrl: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { filePathProps, fileType }, ctx: { user } }) => {
|
||||
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message:
|
||||
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY',
|
||||
})
|
||||
|
||||
if ('resultId' in filePathProps && !user)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You must be logged in to upload a file',
|
||||
})
|
||||
|
||||
const filePath = await parseFilePath({
|
||||
authenticatedUserId: user?.id,
|
||||
uploadProps: filePathProps,
|
||||
})
|
||||
|
||||
const presignedUrl = await generatePresignedUrl({
|
||||
fileType,
|
||||
filePath,
|
||||
})
|
||||
|
||||
return {
|
||||
presignedUrl,
|
||||
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
|
||||
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
|
||||
: presignedUrl.split('?')[0],
|
||||
}
|
||||
})
|
||||
|
||||
type Props = {
|
||||
authenticatedUserId?: string
|
||||
uploadProps: FilePathUploadProps
|
||||
}
|
||||
|
||||
const parseFilePath = async ({
|
||||
authenticatedUserId,
|
||||
uploadProps: input,
|
||||
}: Props): Promise<string> => {
|
||||
if (!authenticatedUserId)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You must be logged in to upload this type of file',
|
||||
})
|
||||
if ('userId' in input) {
|
||||
if (input.userId !== authenticatedUserId)
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You are not authorized to upload a file for this user',
|
||||
})
|
||||
return `public/users/${input.userId}/${input.fileName}`
|
||||
}
|
||||
if (!('workspaceId' in input))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'workspaceId is missing',
|
||||
})
|
||||
if (!('typebotId' in input)) {
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: {
|
||||
id: input.workspaceId,
|
||||
},
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (
|
||||
!workspace ||
|
||||
isWriteWorkspaceForbidden(workspace, { id: authenticatedUserId })
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
return `public/workspaces/${input.workspaceId}/${input.fileName}`
|
||||
}
|
||||
const typebot = await prisma.typebot.findUnique({
|
||||
where: {
|
||||
id: input.typebotId,
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
collaborators: {
|
||||
select: {
|
||||
userId: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (
|
||||
!typebot ||
|
||||
(await isWriteTypebotForbidden(typebot, {
|
||||
id: authenticatedUserId,
|
||||
}))
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Typebot not found',
|
||||
})
|
||||
if (!('blockId' in input)) {
|
||||
return `public/workspaces/${input.workspaceId}/typebots/${input.typebotId}/${input.fileName}`
|
||||
}
|
||||
return `public/workspaces/${input.workspaceId}/typebots/${
|
||||
input.typebotId
|
||||
}/blocks/${input.blockId}${input.itemId ? `/items/${input.itemId}` : ''}`
|
||||
}
|
@ -24,9 +24,7 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
|
||||
updateWorkspace({ name })
|
||||
}
|
||||
|
||||
const handleChangeIcon = (icon: string) => {
|
||||
updateWorkspace({ icon })
|
||||
}
|
||||
const handleChangeIcon = (icon: string) => updateWorkspace({ icon })
|
||||
|
||||
const handleDeleteClick = async () => {
|
||||
await deleteCurrentWorkspace()
|
||||
@ -40,7 +38,10 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
|
||||
<Flex>
|
||||
{workspace && (
|
||||
<EditableEmojiOrImageIcon
|
||||
uploadFilePath={`workspaces/${workspace.id}/icon`}
|
||||
uploadFileProps={{
|
||||
workspaceId: workspace.id,
|
||||
fileName: 'icon',
|
||||
}}
|
||||
icon={workspace.icon}
|
||||
onChangeIcon={handleChangeIcon}
|
||||
boxSize="40px"
|
||||
|
@ -4,7 +4,7 @@ export const isWriteWorkspaceForbidden = (
|
||||
workspace: {
|
||||
members: Pick<MemberInWorkspace, 'userId' | 'role'>[]
|
||||
},
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
user: Pick<User, 'id'>
|
||||
) => {
|
||||
const userRole = workspace.members.find(
|
||||
(member) => member.userId === user.id
|
@ -15,6 +15,7 @@ import { collaboratorsRouter } from '@/features/collaboration/api/router'
|
||||
import { customDomainsRouter } from '@/features/customDomains/api/router'
|
||||
import { whatsAppRouter } from '@/features/whatsapp/router'
|
||||
import { openAIRouter } from '@/features/blocks/integrations/openai/api/router'
|
||||
import { generateUploadUrl } from '@/features/upload/api/generateUploadUrl'
|
||||
|
||||
export const trpcRouter = router({
|
||||
getAppVersionProcedure,
|
||||
@ -33,6 +34,7 @@ export const trpcRouter = router({
|
||||
customDomains: customDomainsRouter,
|
||||
whatsApp: whatsAppRouter,
|
||||
openAI: openAIRouter,
|
||||
generateUploadUrl,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof trpcRouter
|
||||
|
Reference in New Issue
Block a user