2
0

Add Unsplash picker

Closes #413
This commit is contained in:
Baptiste Arnaud
2023-03-29 15:20:01 +02:00
parent 80b7dbd19e
commit 3ef4efab4e
13 changed files with 295 additions and 16 deletions

View File

@ -8,3 +8,5 @@ NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_VIEWER_INTERNAL_URL= NEXT_PUBLIC_VIEWER_INTERNAL_URL=
NEXT_PUBLIC_E2E_TEST= NEXT_PUBLIC_E2E_TEST=
NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME= NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME=
NEXT_PUBLIC_UNSPLASH_APP_NAME=
NEXT_PUBLIC_UNSPLASH_ACCESS_KEY=

View File

@ -37,7 +37,9 @@
"@trpc/next": "10.16.0", "@trpc/next": "10.16.0",
"@trpc/react-query": "10.16.0", "@trpc/react-query": "10.16.0",
"@trpc/server": "10.16.0", "@trpc/server": "10.16.0",
"@typebot.io/emails": "workspace:*",
"@typebot.io/js": "workspace:*", "@typebot.io/js": "workspace:*",
"@typebot.io/next-international": "0.3.8",
"@typebot.io/react": "workspace:*", "@typebot.io/react": "workspace:*",
"@udecode/plate-basic-marks": "20.4.0", "@udecode/plate-basic-marks": "20.4.0",
"@udecode/plate-common": "^20.4.0", "@udecode/plate-common": "^20.4.0",
@ -57,7 +59,6 @@
"codemirror": "6.0.1", "codemirror": "6.0.1",
"deep-object-diff": "1.1.9", "deep-object-diff": "1.1.9",
"dequal": "2.0.3", "dequal": "2.0.3",
"@typebot.io/emails": "workspace:*",
"emojilib": "3.0.8", "emojilib": "3.0.8",
"focus-visible": "5.2.0", "focus-visible": "5.2.0",
"framer-motion": "10.3.0", "framer-motion": "10.3.0",
@ -73,7 +74,6 @@
"minio": "7.0.32", "minio": "7.0.32",
"next": "13.2.4", "next": "13.2.4",
"next-auth": "4.19.2", "next-auth": "4.19.2",
"@typebot.io/next-international": "0.3.8",
"nextjs-cors": "^2.1.2", "nextjs-cors": "^2.1.2",
"nodemailer": "6.9.1", "nodemailer": "6.9.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
@ -93,12 +93,17 @@
"swr": "2.1.0", "swr": "2.1.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"trpc-openapi": "1.1.2", "trpc-openapi": "1.1.2",
"unsplash-js": "^7.0.15",
"use-debounce": "9.0.3" "use-debounce": "9.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.21.0", "@babel/core": "7.21.0",
"@chakra-ui/styled-system": "2.6.1", "@chakra-ui/styled-system": "2.6.1",
"@playwright/test": "1.31.2", "@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/canvas-confetti": "1.6.0",
"@types/google-spreadsheet": "3.3.1", "@types/google-spreadsheet": "3.3.1",
"@types/jsonwebtoken": "9.0.1", "@types/jsonwebtoken": "9.0.1",
@ -112,15 +117,11 @@
"@types/qs": "6.9.7", "@types/qs": "6.9.7",
"@types/react": "18.0.28", "@types/react": "18.0.28",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@typebot.io/prisma": "workspace:*",
"dotenv": "16.0.3", "dotenv": "16.0.3",
"eslint": "8.36.0", "eslint": "8.36.0",
"eslint-config-custom": "workspace:*", "eslint-config-custom": "workspace:*",
"@typebot.io/schemas": "workspace:*",
"superjson": "^1.12.2", "superjson": "^1.12.2",
"@typebot.io/tsconfig": "workspace:*",
"typescript": "4.9.5", "typescript": "4.9.5",
"@typebot.io/lib": "workspace:*",
"zod": "3.21.4" "zod": "3.21.4"
} }
} }

View File

@ -58,6 +58,7 @@ export const EditableEmojiOrImageIcon = ({
defaultUrl={icon ?? ''} defaultUrl={icon ?? ''}
onSubmit={onChangeIcon} onSubmit={onChangeIcon}
isGiphyEnabled={false} isGiphyEnabled={false}
isUnsplashEnabled={false}
isEmojiEnabled={true} isEmojiEnabled={true}
onClose={onClose} onClose={onClose}
/> />

View File

@ -1,7 +1,7 @@
import { Flex, Stack, Text } from '@chakra-ui/react' import { Flex, Stack, Text } from '@chakra-ui/react'
import { GiphyFetch } from '@giphy/js-fetch-api' import { GiphyFetch } from '@giphy/js-fetch-api'
import { Grid } from '@giphy/react-components' import { Grid } from '@giphy/react-components'
import { GiphyLogo } from './GiphyLogo' import { GiphyLogo } from '../logos/GiphyLogo'
import React, { useState } from 'react' import React, { useState } from 'react'
import { env, isEmpty } from '@typebot.io/lib' import { env, isEmpty } from '@typebot.io/lib'
import { TextInput } from '../inputs' import { TextInput } from '../inputs'
@ -12,7 +12,7 @@ type GiphySearchFormProps = {
const giphyFetch = new GiphyFetch(env('GIPHY_API_KEY') as string) 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 [inputValue, setInputValue] = useState('')
const fetchGifs = (offset: number) => const fetchGifs = (offset: number) =>
@ -24,7 +24,7 @@ export const GiphySearchForm = ({ onSubmit }: GiphySearchFormProps) => {
return isEmpty(env('GIPHY_API_KEY')) ? ( return isEmpty(env('GIPHY_API_KEY')) ? (
<Text>NEXT_PUBLIC_GIPHY_API_KEY is missing in environment</Text> <Text>NEXT_PUBLIC_GIPHY_API_KEY is missing in environment</Text>
) : ( ) : (
<Stack> <Stack spacing={4} pt="2">
<Flex align="center"> <Flex align="center">
<TextInput <TextInput
autoFocus autoFocus

View File

@ -1,9 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Flex, HStack, Stack } from '@chakra-ui/react' import { Button, Flex, HStack, Stack } from '@chakra-ui/react'
import { UploadButton } from './UploadButton' import { UploadButton } from './UploadButton'
import { GiphySearchForm } from './GiphySearchForm' import { GiphyPicker } from './GiphyPicker'
import { TextInput } from '../inputs/TextInput' import { TextInput } from '../inputs/TextInput'
import { EmojiSearchableList } from './emoji/EmojiSearchableList' import { EmojiSearchableList } from './emoji/EmojiSearchableList'
import { UnsplashPicker } from './UnsplashPicker'
type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash'
type Props = { type Props = {
filePath: string filePath: string
@ -11,6 +14,8 @@ type Props = {
defaultUrl?: string defaultUrl?: string
isEmojiEnabled?: boolean isEmojiEnabled?: boolean
isGiphyEnabled?: boolean isGiphyEnabled?: boolean
isUnsplashEnabled?: boolean
imageSize?: 'small' | 'regular' | 'thumb'
onSubmit: (url: string) => void onSubmit: (url: string) => void
onClose?: () => void onClose?: () => void
} }
@ -22,11 +27,13 @@ export const ImageUploadContent = ({
onSubmit, onSubmit,
isEmojiEnabled = false, isEmojiEnabled = false,
isGiphyEnabled = true, isGiphyEnabled = true,
isUnsplashEnabled = true,
imageSize = 'regular',
onClose, onClose,
}: Props) => { }: Props) => {
const [currentTab, setCurrentTab] = useState< const [currentTab, setCurrentTab] = useState<Tabs>(
'link' | 'upload' | 'giphy' | 'emoji' isEmojiEnabled ? 'emoji' : 'upload'
>(isEmojiEnabled ? 'emoji' : 'link') )
const handleSubmit = (url: string) => { const handleSubmit = (url: string) => {
onSubmit(url) onSubmit(url)
@ -68,12 +75,22 @@ export const ImageUploadContent = ({
Giphy Giphy
</Button> </Button>
)} )}
{isUnsplashEnabled && (
<Button
variant={currentTab === 'unsplash' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('unsplash')}
size="sm"
>
Unsplash
</Button>
)}
</HStack> </HStack>
<BodyContent <BodyContent
filePath={filePath} filePath={filePath}
includeFileName={includeFileName} includeFileName={includeFileName}
tab={currentTab} tab={currentTab}
imageSize={imageSize}
onSubmit={handleSubmit} onSubmit={handleSubmit}
defaultUrl={defaultUrl} defaultUrl={defaultUrl}
/> />
@ -86,12 +103,14 @@ const BodyContent = ({
filePath, filePath,
tab, tab,
defaultUrl, defaultUrl,
imageSize,
onSubmit, onSubmit,
}: { }: {
includeFileName?: boolean includeFileName?: boolean
filePath: string filePath: string
tab: 'upload' | 'link' | 'giphy' | 'emoji' tab: Tabs
defaultUrl?: string defaultUrl?: string
imageSize: 'small' | 'regular' | 'thumb'
onSubmit: (url: string) => void onSubmit: (url: string) => void
}) => { }) => {
switch (tab) { switch (tab) {
@ -109,6 +128,8 @@ const BodyContent = ({
return <GiphyContent onNewUrl={onSubmit} /> return <GiphyContent onNewUrl={onSubmit} />
case 'emoji': case 'emoji':
return <EmojiSearchableList onEmojiSelected={onSubmit} /> return <EmojiSearchableList onEmojiSelected={onSubmit} />
case 'unsplash':
return <UnsplashPicker imageSize={imageSize} onImageSelect={onSubmit} />
} }
} }
@ -146,5 +167,5 @@ const EmbedLinkContent = ({
) )
const GiphyContent = ({ onNewUrl }: ContentProps) => ( const GiphyContent = ({ onNewUrl }: ContentProps) => (
<GiphySearchForm onSubmit={onNewUrl} /> <GiphyPicker onSubmit={onNewUrl} />
) )

View File

@ -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<UnsplashImage[]>([])
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 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 (
<Text>NEXT_PUBLIC_UNSPLASH_ACCESS_KEY is missing in environment</Text>
)
return (
<Stack spacing={4} pt="2">
<HStack align="center">
<TextInput
autoFocus
placeholder="Search..."
onChange={(query) => {
setSearchQuery(query)
fetchNewImages(query, 0)
}}
withVariableButton={false}
/>
<Link
isExternal
href={`https://unsplash.com/?utm_source=${env(
'UNSPLASH_APP_NAME'
)}&utm_medium=referral`}
>
<UnsplashLogo width="80px" />
</Link>
</HStack>
{isDefined(error) && (
<Alert status="error">
<AlertIcon />
{error}
</Alert>
)}
<Stack overflowY="scroll" maxH="400px" ref={scrollContainer}>
{images.length > 0 && (
<Grid templateColumns="repeat(4, 1fr)" columnGap={2} rowGap={3}>
{images.map((image, index) => (
<GridItem
as={Stack}
key={image.id}
boxSize="100%"
spacing="0"
ref={index === images.length - 1 ? bottomAnchor : undefined}
>
<UnsplashImage
image={image}
onClick={() => selectImage(image)}
/>
</GridItem>
))}
</Grid>
)}
{isFetching && (
<Flex justifyContent="center" py="4">
<Spinner />
</Flex>
)}
</Stack>
</Stack>
)
}
type UnsplashImageProps = {
image: UnsplashImage
onClick: () => void
}
const UnsplashImage = ({ image, onClick }: UnsplashImageProps) => {
const { user, urls, alt_description } = image
return (
<>
<Image
objectFit="cover"
src={urls.thumb}
alt={alt_description ?? 'Unsplash image'}
onClick={onClick}
rounded="md"
h="100%"
cursor="pointer"
/>
<TextLink
fontSize="xs"
isExternal
href={`https://unsplash.com/@${user.username}?utm_source=${env(
'UNSPLASH_APP_NAME'
)}&utm_medium=referral`}
noOfLines={1}
color="gray.500"
>
{user.name}
</TextLink>
</>
)
}

View File

@ -13,6 +13,7 @@ export const TextLink = ({
scroll, scroll,
prefetch, prefetch,
isExternal, isExternal,
noOfLines,
...textProps ...textProps
}: TextLinkProps) => ( }: TextLinkProps) => (
<Link <Link
@ -26,7 +27,9 @@ export const TextLink = ({
<chakra.span textDecor="underline" display="inline-block" {...textProps}> <chakra.span textDecor="underline" display="inline-block" {...textProps}>
{isExternal ? ( {isExternal ? (
<HStack spacing={1}> <HStack spacing={1}>
<chakra.span>{children}</chakra.span> <chakra.span noOfLines={noOfLines} maxW="100%">
{children}
</chakra.span>
<ExternalLinkIcon /> <ExternalLinkIcon />
</HStack> </HStack>
) : ( ) : (

View File

@ -0,0 +1,7 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const UnsplashLogo = (props: IconProps) => (
<Icon viewBox="0 0 457.19 104.19" {...props}>
<path d="M59.88 38.66h27.53v48.75H0V38.66h27.53v24.37h32.35zm93 25c0 8.25-5.45 13.13-12.9 13.13-7.28 0-12.81-4.88-12.81-13.13V24.41h-12.22v39.13c0 15.45 11 25.21 25.06 25.21s25.15-9.76 25.15-25.21V24.41h-12.25zm43.7-21.13c-4.7 0-9.94 2-12.6 6.57v-5.41h-11.45v43.64h11.81v-25.1c0-5 3-9 8.16-9 5.68 0 8.08 3.82 8.08 8.7v25.4h11.8V59.82c.03-9.59-4.94-17.31-15.77-17.31zm43.31 18.37l-6.48-1.33c-2.47-.5-4-1.77-4-3.9 0-2.49 2.23-4.35 5.33-4.35 4.36 0 6.09 2.25 6.51 4.88h10.18c-.08-6-4.83-13.84-16.51-13.84-9.41 0-16.33 6.47-16.33 14.28 0 6.13 3.81 11.19 12.24 13l6.05 1.33c3.37.71 4.7 2.31 4.7 4.26 0 2.31-2.14 4.35-6 4.35-4.71 0-7.27-2.68-7.87-5.79h-10.5c.59 6.53 5.32 14.84 18.46 14.84 11.45 0 17.22-7.28 17.22-14.38-.01-6.36-4.36-11.59-12.97-13.37zm63.19 4.53c0 13.22-8.26 23-20.59 23-6 0-10.48-2.4-12.61-5.33v21.13h-11.8V43.67h11.45v5.41c2-3.37 6.83-6.39 13.4-6.39 12.81 0 20.18 9.76 20.18 22.72zm-11.63.09c0-7.72-4.79-12.25-10.83-12.25s-10.91 4.53-10.91 12.25 4.88 12.33 10.91 12.33 10.91-4.54 10.91-12.35zm68-21.83h11.45v43.64h-11.8v-5.31c-2 3.5-6.57 6.38-12.61 6.38-12.33 0-20.59-9.77-20.59-23 0-13 7.37-22.72 20.15-22.72 6.57 0 11.32 3.05 13.4 6.39zm-.18 21.83c0-7.72-4.88-12.25-10.91-12.25s-10.83 4.51-10.83 12.23 4.79 12.33 10.83 12.33 10.92-4.6 10.92-12.33zm-50.66 21.81h11.8V24.41h-11.8zm132.35-44.81c-4.17 0-9 1.41-11.81 4.78V24.41h-11.8v62.91h11.8V61.68c.27-4.8 3.2-8.52 8.17-8.52 5.68 0 8.08 3.83 8.07 8.71v25.47h11.81V59.82c-.01-9.59-5.15-17.3-16.24-17.3zm-42 18.36l-6.43-1.33c-2.47-.5-4-1.77-4-3.9 0-2.49 2.22-4.35 5.33-4.35 4.35 0 6.08 2.25 6.5 4.88h10.17c-.08-6-4.83-13.84-16.51-13.84-9.41 0-16.33 6.47-16.33 14.28 0 6.13 3.82 11.19 12.25 13l6 1.33c3.37.71 4.7 2.31 4.7 4.26 0 2.31-2.14 4.35-6 4.35-4.71 0-7.27-2.68-7.87-5.79h-10.49c.58 6.53 5.31 14.84 18.45 14.84 11.45 0 17.22-7.28 17.22-14.38 0-6.34-4.35-11.57-12.95-13.35zM59.88 0H27.53v24.37h32.35z" />
</Icon>
)

View File

@ -65,6 +65,7 @@ export const MetadataForm = ({
defaultUrl={metadata.favIconUrl ?? ''} defaultUrl={metadata.favIconUrl ?? ''}
onSubmit={handleFavIconSubmit} onSubmit={handleFavIconSubmit}
isGiphyEnabled={false} isGiphyEnabled={false}
imageSize="thumb"
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@ -91,6 +91,7 @@ export const AvatarForm = ({
<ImageUploadContent <ImageUploadContent
filePath={uploadFilePath} filePath={uploadFilePath}
defaultUrl={avatarProps?.url} defaultUrl={avatarProps?.url}
imageSize="thumb"
onSubmit={handleImageUrl} onSubmit={handleImageUrl}
/> />
</PopoverContent> </PopoverContent>

View File

@ -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 | | 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 ## Others
<details><summary><h3>Show</h3></summary> <details><summary><h3>Show</h3></summary>

14
pnpm-lock.yaml generated
View File

@ -121,6 +121,7 @@ importers:
tinycolor2: 1.6.0 tinycolor2: 1.6.0
trpc-openapi: 1.1.2 trpc-openapi: 1.1.2
typescript: 4.9.5 typescript: 4.9.5
unsplash-js: ^7.0.15
use-debounce: 9.0.3 use-debounce: 9.0.3
zod: 3.21.4 zod: 3.21.4
dependencies: dependencies:
@ -204,6 +205,7 @@ importers:
swr: 2.1.0_react@18.2.0 swr: 2.1.0_react@18.2.0
tinycolor2: 1.6.0 tinycolor2: 1.6.0
trpc-openapi: 1.1.2_k6tpxpkoeqrvdlcqns422rglmm trpc-openapi: 1.1.2_k6tpxpkoeqrvdlcqns422rglmm
unsplash-js: 7.0.15
use-debounce: 9.0.3_react@18.2.0 use-debounce: 9.0.3_react@18.2.0
devDependencies: devDependencies:
'@babel/core': 7.21.0 '@babel/core': 7.21.0
@ -6829,6 +6831,10 @@ packages:
'@types/node': 18.15.3 '@types/node': 18.15.3
dev: false dev: false
/@types/content-type/1.1.5:
resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==}
dev: false
/@types/cors/2.8.13: /@types/cors/2.8.13:
resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
dependencies: dependencies:
@ -19688,6 +19694,14 @@ packages:
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dev: false 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: /untildify/4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'} engines: {node: '>=8'}