Skip to content

Commit

Permalink
✨ Implement Pexels videos option to media popover (#1636)
Browse files Browse the repository at this point in the history
Closes #1575 

Note: Need to create a new environment variable named
`NEXT_PUBLIC_PEXELS_API_KEY` to store the API Key obtained from Pexels!


https://github.com/user-attachments/assets/4250f799-0bd7-48e9-b9a8-4bc188ad7704

---------

Co-authored-by: Baptiste Arnaud <[email protected]>
Co-authored-by: younesbenallal <[email protected]>
  • Loading branch information
3 people authored Jul 22, 2024
1 parent 94ca8ac commit 09277c2
Show file tree
Hide file tree
Showing 10 changed files with 507 additions and 98 deletions.
5 changes: 3 additions & 2 deletions apps/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"nprogress": "0.2.0",
"openai": "4.47.1",
"papaparse": "5.4.1",
"pexels": "^1.4.0",
"prettier": "2.8.8",
"qs": "6.11.2",
"react": "18.2.0",
Expand Down Expand Up @@ -113,6 +114,7 @@
"@typebot.io/schemas": "workspace:*",
"@typebot.io/telemetry": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@typebot.io/variables": "workspace:*",
"@types/canvas-confetti": "1.6.0",
"@types/jsonwebtoken": "9.0.2",
"@types/micro-cors": "0.1.3",
Expand All @@ -131,7 +133,6 @@
"next-runtime-env": "1.6.2",
"superjson": "1.12.4",
"typescript": "5.4.5",
"zod": "3.22.4",
"@typebot.io/variables": "workspace:*"
"zod": "3.22.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const GiphyPicker = ({ onSubmit }: GiphySearchFormProps) => {
placeholder="Search..."
onChange={setInputValue}
withVariableButton={false}
width="full"
/>
<GiphyLogo w="100px" />
</Flex>
Expand Down
254 changes: 254 additions & 0 deletions apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
Alert,
AlertIcon,
Box,
Flex,
Grid,
GridItem,
HStack,
Image,
Link,
Spinner,
Stack,
Text,
} from '@chakra-ui/react'
import { isDefined } from '@typebot.io/lib'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createClient, Video, ErrorResponse, Videos } from 'pexels'
import { TextInput } from '../inputs'
import { TextLink } from '../TextLink'
import { env } from '@typebot.io/env'
import { PexelsLogo } from '../logos/PexelsLogo'

const client = createClient(env.NEXT_PUBLIC_PEXELS_API_KEY ?? 'dummy')

type Props = {
videoSize: 'large' | 'medium' | 'small'
onVideoSelect: (videoUrl: string) => void
}

export const PexelsPicker = ({ videoSize, onVideoSelect }: Props) => {
const [isFetching, setIsFetching] = useState(false)
const [videos, setVideos] = useState<Video[]>([])
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const scrollContainer = useRef<HTMLDivElement>(null)
const bottomAnchor = useRef<HTMLDivElement>(null)

const [nextPage, setNextPage] = useState(0)

const fetchNewVideos = useCallback(async (query: string, page: number) => {
if (query === '') getInitialVideos()
if (query.length <= 2) {
setNextPage(0)
return
}
setError(null)
setIsFetching(true)
try {
const result = await client.videos.search({
query,
per_page: 24,
size: videoSize,
page,
})
if ((result as ErrorResponse).error)
setError((result as ErrorResponse).error)
if (isDefined((result as Videos).videos)) {
if (page === 0) setVideos((result as Videos).videos)
else
setVideos((videos) => [
...videos,
...((result as Videos)?.videos ?? []),
])
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[]) => {
const target = entities[0]
if (target.isIntersecting) fetchNewVideos(searchQuery, nextPage + 1)
},
{
root: scrollContainer.current,
}
)
if (bottomAnchor.current && nextPage > 0)
observer.observe(bottomAnchor.current)
return () => {
observer.disconnect()
}
}, [fetchNewVideos, nextPage, searchQuery])

const getInitialVideos = async () => {
setError(null)
setIsFetching(true)
client.videos
.popular({
per_page: 24,
size: videoSize,
})
.then((res) => {
if ((res as ErrorResponse).error) {
setError((res as ErrorResponse).error)
}
setVideos((res as Videos).videos)
setIsFetching(false)
})
.catch((err) => {
if (err && typeof err === 'object' && 'message' in err)
setError(err.message as string)
setError('Something went wrong')
setIsFetching(false)
})
}

const selectVideo = (video: Video) => {
const videoUrl = video.video_files[0].link
if (isDefined(videoUrl)) onVideoSelect(videoUrl)
}

useEffect(() => {
if (!env.NEXT_PUBLIC_PEXELS_API_KEY) return
getInitialVideos()
}, [])

if (!env.NEXT_PUBLIC_PEXELS_API_KEY)
return <Text>NEXT_PUBLIC_PEXELS_API_KEY is missing in environment</Text>

return (
<Stack spacing={4} pt="2">
<HStack align="center">
<TextInput
autoFocus
placeholder="Search..."
onChange={(query) => {
setSearchQuery(query)
fetchNewVideos(query, 0)
}}
withVariableButton={false}
debounceTimeout={500}
forceDebounce
width="full"
/>
<Link isExternal href={`https://www.pexels.com`}>
<PexelsLogo width="100px" height="40px" />
</Link>
</HStack>
{isDefined(error) && (
<Alert status="error">
<AlertIcon />
{error}
</Alert>
)}
<Stack overflowY="auto" maxH="400px" ref={scrollContainer}>
{videos.length > 0 && (
<Grid templateColumns="repeat(3, 1fr)" columnGap={2} rowGap={3}>
{videos.map((video, index) => (
<GridItem
as={Stack}
key={video.id}
boxSize="100%"
spacing="0"
ref={index === videos.length - 1 ? bottomAnchor : undefined}
>
<PexelsVideo video={video} onClick={() => selectVideo(video)} />
</GridItem>
))}
</Grid>
)}
{isFetching && (
<Flex justifyContent="center" py="4">
<Spinner />
</Flex>
)}
</Stack>
</Stack>
)
}

type PexelsVideoProps = {
video: Video
onClick: () => void
}

const PexelsVideo = ({ video, onClick }: PexelsVideoProps) => {
const { user, url, video_pictures } = video
const [isImageHovered, setIsImageHovered] = useState(false)
const [thumbnailImage, setThumbnailImage] = useState(
video_pictures[0].picture
)
const [imageIndex, setImageIndex] = useState(1)

useEffect(() => {
let interval: NodeJS.Timer

if (isImageHovered && video_pictures.length > 0) {
interval = setInterval(() => {
setImageIndex((prevIndex) => (prevIndex + 1) % video_pictures.length)
setThumbnailImage(video_pictures[imageIndex].picture)
}, 200)
} else {
setThumbnailImage(video_pictures[0].picture)
setImageIndex(1)
}

return () => {
if (interval) {
clearInterval(interval)
}
}
}, [isImageHovered, imageIndex, video_pictures])

return (
<Box
pos="relative"
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
h="full"
>
{
<Image
objectFit="cover"
src={thumbnailImage}
alt={`Pexels Video ${video.id}`}
onClick={onClick}
rounded="md"
h="100%"
aspectRatio={4 / 3}
cursor="pointer"
/>
}
<Box
pos="absolute"
bottom={0}
left={0}
bgColor="rgba(0,0,0,.5)"
px="2"
rounded="md"
opacity={isImageHovered ? 1 : 0}
transition="opacity .2s ease-in-out"
>
<TextLink
fontSize="xs"
isExternal
href={url}
noOfLines={1}
color="white"
>
{user.name}
</TextLink>
</Box>
</Box>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Stack, Text } from '@chakra-ui/react'
import { useTranslate } from '@tolgee/react'
import { VideoBubbleBlock } from '@typebot.io/schemas'
import { TextInput } from '@/components/inputs'
import { defaultVideoBubbleContent } from '@typebot.io/schemas/features/blocks/bubbles/video/constants'
import { SwitchWithLabel } from '../inputs/SwitchWithLabel'

export const VideoLinkEmbedContent = ({
content,
updateUrl,
onSubmit,
}: {
content?: VideoBubbleBlock['content']
updateUrl: (url: string) => void
onSubmit: (content: VideoBubbleBlock['content']) => void
}) => {
const { t } = useTranslate()

const updateAspectRatio = (aspectRatio?: string) => {
return onSubmit({
...content,
aspectRatio,
})
}

const updateMaxWidth = (maxWidth?: string) => {
return onSubmit({
...content,
maxWidth,
})
}

const updateAutoPlay = (isAutoplayEnabled: boolean) => {
return onSubmit({ ...content, isAutoplayEnabled })
}

const updateControlsDisplay = (areControlsDisplayed: boolean) => {
if (areControlsDisplayed === false) {
// Make sure autoplay is enabled when video controls are disabled
return onSubmit({
...content,
isAutoplayEnabled: true,
areControlsDisplayed,
})
}
return onSubmit({ ...content, areControlsDisplayed })
}

return (
<>
<Stack py="2">
<TextInput
placeholder={t('video.urlInput.placeholder')}
defaultValue={content?.url ?? ''}
onChange={updateUrl}
/>
<Text fontSize="xs" color="gray.400" textAlign="center">
{t('video.urlInput.helperText')}
</Text>
</Stack>
{content?.url && (
<Stack>
<TextInput
label={t('video.aspectRatioInput.label')}
moreInfoTooltip={t('video.aspectRatioInput.moreInfoTooltip')}
defaultValue={
content?.aspectRatio ?? defaultVideoBubbleContent.aspectRatio
}
onChange={updateAspectRatio}
direction="row"
/>
<TextInput
label={t('video.maxWidthInput.label')}
moreInfoTooltip={t('video.maxWidthInput.moreInfoTooltip')}
defaultValue={
content?.maxWidth ?? defaultVideoBubbleContent.maxWidth
}
onChange={updateMaxWidth}
direction="row"
/>
</Stack>
)}
{content?.url && content?.type === 'url' && (
<Stack>
<SwitchWithLabel
label={'Display controls'}
initialValue={
content?.areControlsDisplayed ??
defaultVideoBubbleContent.areControlsDisplayed
}
onCheckChange={updateControlsDisplay}
/>
<SwitchWithLabel
label={t('editor.blocks.bubbles.audio.settings.autoplay.label')}
initialValue={
content?.isAutoplayEnabled ??
defaultVideoBubbleContent.isAutoplayEnabled
}
isChecked={
content?.isAutoplayEnabled ??
defaultVideoBubbleContent.isAutoplayEnabled
}
isDisabled={content?.areControlsDisplayed === false}
onCheckChange={() => updateAutoPlay(!content.isAutoplayEnabled)}
/>
</Stack>
)}
</>
)
}
Loading

0 comments on commit 09277c2

Please sign in to comment.