-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Implement Pexels videos option to media popover (#1636)
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
1 parent
94ca8ac
commit 09277c2
Showing
10 changed files
with
507 additions
and
98 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
254 changes: 254 additions & 0 deletions
254
apps/builder/src/components/VideoUploadContent/PexelsPicker.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
110 changes: 110 additions & 0 deletions
110
apps/builder/src/components/VideoUploadContent/VideoLinkEmbedContent.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
)} | ||
</> | ||
) | ||
} |
Oops, something went wrong.