From 3ef4efab4e0d85839f07de94456e8a2762ffa416 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Wed, 29 Mar 2023 15:20:01 +0200 Subject: [PATCH] :sparkles: Add Unsplash picker Closes #413 --- apps/builder/.env.docker | 2 + apps/builder/package.json | 13 +- .../components/EditableEmojiOrImageIcon.tsx | 1 + .../{GiphySearchForm.tsx => GiphyPicker.tsx} | 6 +- .../ImageUploadContent/ImageUploadContent.tsx | 33 ++- .../ImageUploadContent/UnsplashPicker.tsx | 219 ++++++++++++++++++ apps/builder/src/components/TextLink.tsx | 5 +- .../GiphyLogo.tsx | 0 .../src/components/logos/UnsplashLogo.tsx | 7 + .../settings/components/MetadataForm.tsx | 1 + .../theme/components/chat/AvatarForm.tsx | 1 + .../self-hosting/configuration/builder.mdx | 9 + pnpm-lock.yaml | 14 ++ 13 files changed, 295 insertions(+), 16 deletions(-) rename apps/builder/src/components/ImageUploadContent/{GiphySearchForm.tsx => GiphyPicker.tsx} (90%) create mode 100644 apps/builder/src/components/ImageUploadContent/UnsplashPicker.tsx rename apps/builder/src/components/{ImageUploadContent => logos}/GiphyLogo.tsx (100%) create mode 100644 apps/builder/src/components/logos/UnsplashLogo.tsx diff --git a/apps/builder/.env.docker b/apps/builder/.env.docker index afd2b11fb0..b4ee669d86 100644 --- a/apps/builder/.env.docker +++ b/apps/builder/.env.docker @@ -8,3 +8,5 @@ NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_VIEWER_INTERNAL_URL= NEXT_PUBLIC_E2E_TEST= NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME= +NEXT_PUBLIC_UNSPLASH_APP_NAME= +NEXT_PUBLIC_UNSPLASH_ACCESS_KEY= diff --git a/apps/builder/package.json b/apps/builder/package.json index cae45f8988..de59ea8547 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -37,7 +37,9 @@ "@trpc/next": "10.16.0", "@trpc/react-query": "10.16.0", "@trpc/server": "10.16.0", + "@typebot.io/emails": "workspace:*", "@typebot.io/js": "workspace:*", + "@typebot.io/next-international": "0.3.8", "@typebot.io/react": "workspace:*", "@udecode/plate-basic-marks": "20.4.0", "@udecode/plate-common": "^20.4.0", @@ -57,7 +59,6 @@ "codemirror": "6.0.1", "deep-object-diff": "1.1.9", "dequal": "2.0.3", - "@typebot.io/emails": "workspace:*", "emojilib": "3.0.8", "focus-visible": "5.2.0", "framer-motion": "10.3.0", @@ -73,7 +74,6 @@ "minio": "7.0.32", "next": "13.2.4", "next-auth": "4.19.2", - "@typebot.io/next-international": "0.3.8", "nextjs-cors": "^2.1.2", "nodemailer": "6.9.1", "nprogress": "0.2.0", @@ -93,12 +93,17 @@ "swr": "2.1.0", "tinycolor2": "1.6.0", "trpc-openapi": "1.1.2", + "unsplash-js": "^7.0.15", "use-debounce": "9.0.3" }, "devDependencies": { "@babel/core": "7.21.0", "@chakra-ui/styled-system": "2.6.1", "@playwright/test": "1.31.2", + "@typebot.io/lib": "workspace:*", + "@typebot.io/prisma": "workspace:*", + "@typebot.io/schemas": "workspace:*", + "@typebot.io/tsconfig": "workspace:*", "@types/canvas-confetti": "1.6.0", "@types/google-spreadsheet": "3.3.1", "@types/jsonwebtoken": "9.0.1", @@ -112,15 +117,11 @@ "@types/qs": "6.9.7", "@types/react": "18.0.28", "@types/tinycolor2": "1.4.3", - "@typebot.io/prisma": "workspace:*", "dotenv": "16.0.3", "eslint": "8.36.0", "eslint-config-custom": "workspace:*", - "@typebot.io/schemas": "workspace:*", "superjson": "^1.12.2", - "@typebot.io/tsconfig": "workspace:*", "typescript": "4.9.5", - "@typebot.io/lib": "workspace:*", "zod": "3.21.4" } } diff --git a/apps/builder/src/components/EditableEmojiOrImageIcon.tsx b/apps/builder/src/components/EditableEmojiOrImageIcon.tsx index 24a12e0df3..8dc9cf793e 100644 --- a/apps/builder/src/components/EditableEmojiOrImageIcon.tsx +++ b/apps/builder/src/components/EditableEmojiOrImageIcon.tsx @@ -58,6 +58,7 @@ export const EditableEmojiOrImageIcon = ({ defaultUrl={icon ?? ''} onSubmit={onChangeIcon} isGiphyEnabled={false} + isUnsplashEnabled={false} isEmojiEnabled={true} onClose={onClose} /> diff --git a/apps/builder/src/components/ImageUploadContent/GiphySearchForm.tsx b/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx similarity index 90% rename from apps/builder/src/components/ImageUploadContent/GiphySearchForm.tsx rename to apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx index b5361742f5..8ed9043af5 100644 --- a/apps/builder/src/components/ImageUploadContent/GiphySearchForm.tsx +++ b/apps/builder/src/components/ImageUploadContent/GiphyPicker.tsx @@ -1,7 +1,7 @@ import { Flex, Stack, Text } from '@chakra-ui/react' import { GiphyFetch } from '@giphy/js-fetch-api' import { Grid } from '@giphy/react-components' -import { GiphyLogo } from './GiphyLogo' +import { GiphyLogo } from '../logos/GiphyLogo' import React, { useState } from 'react' import { env, isEmpty } from '@typebot.io/lib' import { TextInput } from '../inputs' @@ -12,7 +12,7 @@ type GiphySearchFormProps = { const giphyFetch = new GiphyFetch(env('GIPHY_API_KEY') as string) -export const GiphySearchForm = ({ onSubmit }: GiphySearchFormProps) => { +export const GiphyPicker = ({ onSubmit }: GiphySearchFormProps) => { const [inputValue, setInputValue] = useState('') const fetchGifs = (offset: number) => @@ -24,7 +24,7 @@ export const GiphySearchForm = ({ onSubmit }: GiphySearchFormProps) => { return isEmpty(env('GIPHY_API_KEY')) ? ( NEXT_PUBLIC_GIPHY_API_KEY is missing in environment ) : ( - + void onClose?: () => void } @@ -22,11 +27,13 @@ export const ImageUploadContent = ({ onSubmit, isEmojiEnabled = false, isGiphyEnabled = true, + isUnsplashEnabled = true, + imageSize = 'regular', onClose, }: Props) => { - const [currentTab, setCurrentTab] = useState< - 'link' | 'upload' | 'giphy' | 'emoji' - >(isEmojiEnabled ? 'emoji' : 'link') + const [currentTab, setCurrentTab] = useState( + isEmojiEnabled ? 'emoji' : 'upload' + ) const handleSubmit = (url: string) => { onSubmit(url) @@ -68,12 +75,22 @@ export const ImageUploadContent = ({ Giphy )} + {isUnsplashEnabled && ( + + )} @@ -86,12 +103,14 @@ const BodyContent = ({ filePath, tab, defaultUrl, + imageSize, onSubmit, }: { includeFileName?: boolean filePath: string - tab: 'upload' | 'link' | 'giphy' | 'emoji' + tab: Tabs defaultUrl?: string + imageSize: 'small' | 'regular' | 'thumb' onSubmit: (url: string) => void }) => { switch (tab) { @@ -109,6 +128,8 @@ const BodyContent = ({ return case 'emoji': return + case 'unsplash': + return } } @@ -146,5 +167,5 @@ const EmbedLinkContent = ({ ) const GiphyContent = ({ onNewUrl }: ContentProps) => ( - + ) diff --git a/apps/builder/src/components/ImageUploadContent/UnsplashPicker.tsx b/apps/builder/src/components/ImageUploadContent/UnsplashPicker.tsx new file mode 100644 index 0000000000..ce6ee4f18e --- /dev/null +++ b/apps/builder/src/components/ImageUploadContent/UnsplashPicker.tsx @@ -0,0 +1,219 @@ +/* eslint-disable @next/next/no-img-element */ +import { + Alert, + AlertIcon, + Flex, + Grid, + GridItem, + HStack, + Image, + Link, + Spinner, + Stack, + Text, +} from '@chakra-ui/react' +import { env, isDefined, isEmpty } from '@typebot.io/lib' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createApi } from 'unsplash-js' +import { Basic as UnsplashImage } from 'unsplash-js/dist/methods/photos/types' +import { TextInput } from '../inputs' +import { UnsplashLogo } from '../logos/UnsplashLogo' +import { TextLink } from '../TextLink' + +const api = createApi({ + accessKey: env('UNSPLASH_ACCESS_KEY') ?? '', +}) + +type Props = { + imageSize: 'regular' | 'small' | 'thumb' + onImageSelect: (imageUrl: string) => void +} + +export const UnsplashPicker = ({ imageSize, onImageSelect }: Props) => { + const [isFetching, setIsFetching] = useState(false) + const [images, setImages] = useState([]) + const [error, setError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const scrollContainer = useRef(null) + const bottomAnchor = useRef(null) + + const [nextPage, setNextPage] = useState(0) + + const fetchNewImages = useCallback(async (query: string, page: number) => { + console.log('Fetch images', query, page) + if (query === '') return searchRandomImages() + if (query.length <= 2) return + setError(null) + setIsFetching(true) + try { + const result = await api.search.getPhotos({ + query, + perPage: 30, + orientation: 'landscape', + page, + }) + if (result.errors) setError(result.errors[0]) + if (isDefined(result.response)) { + if (page === 0) setImages(result.response.results) + else + setImages((images) => [ + ...images, + ...(result.response?.results ?? []), + ]) + setNextPage((page) => page + 1) + } + } catch (err) { + if (err && typeof err === 'object' && 'message' in err) + setError(err.message as string) + setError('Something went wrong') + } + setIsFetching(false) + }, []) + + useEffect(() => { + if (!bottomAnchor.current) return + const observer = new IntersectionObserver( + (entities: IntersectionObserverEntry[]) => { + console.log('Intersection observer', entities) + const target = entities[0] + if (target.isIntersecting) fetchNewImages(searchQuery, nextPage + 1) + }, + { + root: scrollContainer.current, + } + ) + if (bottomAnchor.current && nextPage > 0) + observer.observe(bottomAnchor.current) + return () => { + observer.disconnect() + } + }, [fetchNewImages, nextPage, searchQuery]) + + const searchRandomImages = async () => { + setError(null) + setIsFetching(true) + try { + const result = await api.photos.getRandom({ + count: 30, + orientation: 'landscape', + }) + + if (result.errors) setError(result.errors[0]) + if (isDefined(result.response)) + setImages( + Array.isArray(result.response) ? result.response : [result.response] + ) + } catch (err) { + if (err && typeof err === 'object' && 'message' in err) + setError(err.message as string) + setError('Something went wrong') + } + setIsFetching(false) + } + + const selectImage = (image: UnsplashImage) => { + const url = image.urls[imageSize] + api.photos.trackDownload({ + downloadLocation: image.links.download_location, + }) + if (isDefined(url)) onImageSelect(url) + } + + useEffect(() => { + searchRandomImages() + }, []) + + if (isEmpty(env('UNSPLASH_ACCESS_KEY'))) + return ( + NEXT_PUBLIC_UNSPLASH_ACCESS_KEY is missing in environment + ) + + return ( + + + { + setSearchQuery(query) + fetchNewImages(query, 0) + }} + withVariableButton={false} + /> + + + + + {isDefined(error) && ( + + + {error} + + )} + + {images.length > 0 && ( + + {images.map((image, index) => ( + + selectImage(image)} + /> + + ))} + + )} + {isFetching && ( + + + + )} + + + ) +} + +type UnsplashImageProps = { + image: UnsplashImage + onClick: () => void +} + +const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => { + const { user, urls, alt_description } = image + + return ( + <> + {alt_description + + {user.name} + + + ) +} diff --git a/apps/builder/src/components/TextLink.tsx b/apps/builder/src/components/TextLink.tsx index 0069094a3e..1fdda9f66f 100644 --- a/apps/builder/src/components/TextLink.tsx +++ b/apps/builder/src/components/TextLink.tsx @@ -13,6 +13,7 @@ export const TextLink = ({ scroll, prefetch, isExternal, + noOfLines, ...textProps }: TextLinkProps) => ( {isExternal ? ( - {children} + + {children} + ) : ( diff --git a/apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx b/apps/builder/src/components/logos/GiphyLogo.tsx similarity index 100% rename from apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx rename to apps/builder/src/components/logos/GiphyLogo.tsx diff --git a/apps/builder/src/components/logos/UnsplashLogo.tsx b/apps/builder/src/components/logos/UnsplashLogo.tsx new file mode 100644 index 0000000000..82571facda --- /dev/null +++ b/apps/builder/src/components/logos/UnsplashLogo.tsx @@ -0,0 +1,7 @@ +import { IconProps, Icon } from '@chakra-ui/react' + +export const UnsplashLogo = (props: IconProps) => ( + + + +) diff --git a/apps/builder/src/features/settings/components/MetadataForm.tsx b/apps/builder/src/features/settings/components/MetadataForm.tsx index c580c8db7f..28442b2a00 100644 --- a/apps/builder/src/features/settings/components/MetadataForm.tsx +++ b/apps/builder/src/features/settings/components/MetadataForm.tsx @@ -65,6 +65,7 @@ export const MetadataForm = ({ defaultUrl={metadata.favIconUrl ?? ''} onSubmit={handleFavIconSubmit} isGiphyEnabled={false} + imageSize="thumb" /> diff --git a/apps/builder/src/features/theme/components/chat/AvatarForm.tsx b/apps/builder/src/features/theme/components/chat/AvatarForm.tsx index 0c4cd3b974..c6c72e3db6 100644 --- a/apps/builder/src/features/theme/components/chat/AvatarForm.tsx +++ b/apps/builder/src/features/theme/components/chat/AvatarForm.tsx @@ -91,6 +91,7 @@ export const AvatarForm = ({ diff --git a/apps/docs/docs/self-hosting/configuration/builder.mdx b/apps/docs/docs/self-hosting/configuration/builder.mdx index ee423f75fb..5ae5cfc6a9 100644 --- a/apps/docs/docs/self-hosting/configuration/builder.mdx +++ b/apps/docs/docs/self-hosting/configuration/builder.mdx @@ -180,6 +180,15 @@ Used to search for GIF. You can create a Giphy app [here](https://developers.gip | ------------------------- | ------- | ------------- | | NEXT_PUBLIC_GIPHY_API_KEY | | Giphy API key | +## Unsplash (image picker) + +Used to search for images. You can create a Giphy app [here](https://unsplash.com/developers) + +| Parameter | Default | Description | +| ------------------------------- | ------- | ----------------- | +| NEXT_PUBLIC_UNSPLASH_APP_NAME | | Unsplash App name | +| NEXT_PUBLIC_UNSPLASH_ACCESS_KEY | | Unsplash API key | + ## Others

Show

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23144e2e18..c1123f2304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,7 @@ importers: tinycolor2: 1.6.0 trpc-openapi: 1.1.2 typescript: 4.9.5 + unsplash-js: ^7.0.15 use-debounce: 9.0.3 zod: 3.21.4 dependencies: @@ -204,6 +205,7 @@ importers: swr: 2.1.0_react@18.2.0 tinycolor2: 1.6.0 trpc-openapi: 1.1.2_k6tpxpkoeqrvdlcqns422rglmm + unsplash-js: 7.0.15 use-debounce: 9.0.3_react@18.2.0 devDependencies: '@babel/core': 7.21.0 @@ -6829,6 +6831,10 @@ packages: '@types/node': 18.15.3 dev: false + /@types/content-type/1.1.5: + resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==} + dev: false + /@types/cors/2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: @@ -19688,6 +19694,14 @@ packages: engines: {node: '>= 0.8'} dev: false + /unsplash-js/7.0.15: + resolution: {integrity: sha512-WGqKp9wl2m2tAUPyw2eMZs/KICR+A52tCaRapzVXWxkA4pjHqsaGwiJXTEW7hBy4Pu0QmP6KxTt2jST3tluawA==} + engines: {node: '>=10'} + dependencies: + '@types/content-type': 1.1.5 + content-type: 1.0.5 + dev: false + /untildify/4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'}