Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"lint": "eslint .",
"prettier-check": "prettier --check ./src",
"format:fix": "prettier --write ./src",
"test": "react-scripts test",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"cy:open": "cypress open",
Expand Down
34 changes: 29 additions & 5 deletions src/components/Results/Hit.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Card from 'components/Card'
import BaseLink from 'components/Link'
import Typography from 'components/Typography'
import Highlight from './Highlight'
import extractFirstImageUrl from '../../utils/extractFirstImageUrls'

const EmptyImage = styled.div`
width: 100%;
Expand All @@ -19,6 +20,14 @@ const EmptyImage = styled.div`
border-radius: 10px;
`

const StyledResultImage = styled(LazyLoadImage)`
max-width: 100%;
max-height: 264px;
object-fit: contain;
display: block;
border-radius: 10px;
`

const CustomCard = styled(Card)`
display: flex;
`
Expand Down Expand Up @@ -196,28 +205,43 @@ const FieldValue = ({ hit, objectKey }) => {
)
}

const Hit = ({ hit, imageKey }) => {
const Hit = ({ hit }) => {
const [displayMore, setDisplayMore] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
const hasFields = !!hit._highlightResult
const documentProperties = hasFields
? Object.entries(hit._highlightResult)
: []

const imageSource = extractFirstImageUrl(hit)

useEffect(() => {
if (!hit._highlightResult) {
// eslint-disable-next-line no-console
console.warn('Your hits have no field. Please check your index settings.')
}
}, [])
const altText = hit.title || hit.name || 'Result image'

// Reset image error state when image source changes
useEffect(() => {
setImageError(false)
}, [imageSource])

// Handle image load error
const handleImageError = () => {
setImageError(true)
}

return (
<CustomCard>
<Box width={240} mr={4} flexShrink={0}>
{hit[imageKey] ? (
<LazyLoadImage
src={hit[imageKey] || null}
{imageSource && !imageError ? (
<StyledResultImage
src={imageSource}
alt={altText}
width="100%"
style={{ borderRadius: 10 }}
onError={handleImageError}
/>
) : (
<EmptyImage />
Expand Down
82 changes: 22 additions & 60 deletions src/components/Results/InfiniteHits.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,53 +18,16 @@ const HitsList = styled.ul`
}
`

const isAnImage = async (elem) => {
// Test the standard way with regex and image extensions
if (
typeof elem === 'string' &&
elem.match(/^(https|http):\/\/.*(jpe?g|png|gif|webp)(\?.*)?$/gi)
)
return true

if (typeof elem === 'string' && elem.match(/^https?:\/\//)) {
// Tries to load an image that is a valid URL but doesn't have a correct extension
return new Promise((resolve) => {
const img = new Image()
img.src = elem
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
})
}
return false
}

const findImageKey = async (array) => {
const promises = array.map(async (elem) => isAnImage(elem[1]))
const results = await Promise.all(promises)
const index = results.findIndex((result) => result)
const imageField = array[index]
return imageField?.[0]
}

const InfiniteHits = connectInfiniteHits(({ hits, hasMore, refineNext }) => {
const [imageKey, setImageKey] = React.useState(false)

React.useEffect(() => {
const getImageKey = async () => {
setImageKey(hits[0] ? await findImageKey(Object.entries(hits[0])) : null)
}
getImageKey()
}, [hits[0]])
const InfiniteHits = connectInfiniteHits(({ hits, hasMore, refineNext }) => (
// ({ hits, hasMore, refineNext, mode }) => {
return (
<div>
{/* {mode === 'fancy' ? ( */}
<HitsList>
{hits.map((hit, index) => (
<Hit key={index} hit={hit} imageKey={imageKey} />
))}
</HitsList>
{/* ) : (
<div>
{/* {mode === 'fancy' ? ( */}
<HitsList>
{hits.map((hit, index) => (
<Hit key={index} hit={hit} />
))}
</HitsList>
{/* ) : (
<Card style={{ fontSize: 14, minHeight: 320 }}>
<ReactJson
src={hits}
Expand All @@ -77,19 +40,18 @@ const InfiniteHits = connectInfiniteHits(({ hits, hasMore, refineNext }) => {
/>
</Card>
)} */}
{hasMore && (
<Button
size="small"
variant="bordered"
onClick={refineNext}
style={{ margin: '0 auto', marginTop: 32 }}
>
Load more
</Button>
)}
<ScrollToTop />
</div>
)
})
{hasMore && (
<Button
size="small"
variant="bordered"
onClick={refineNext}
style={{ margin: '0 auto', marginTop: 32 }}
>
Load more
</Button>
)}
<ScrollToTop />
</div>
))

export default InfiniteHits
186 changes: 186 additions & 0 deletions src/utils/extractFirstImageUrl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import extractFirstImageUrl from './extractFirstImageUrls'

describe('extractFirstImageUrl', () => {
test('should extract image URL from simple object', () => {
const input = { image: 'http://example.com/image.png' }
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/image.png')
})

test('should extract image URLs from nested object', () => {
const input = {
details: {
mainImage: 'http://example.com/photo.jpg',
description: 'A beautiful photo',
},
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/photo.jpg')
})

test('should extract the first image URL from array', () => {
const input = {
gallery: ['img1.gif', 'http://example.com/img2.webp'],
}
const result = extractFirstImageUrl(input)
expect(result).toBe('img1.gif')
})

test('should extract first image from object', () => {
const input = {
images: {
png: 'http://example.com/image.png',
jpg: 'http://example.com/photo.jpg',
jpeg: 'http://example.com/picture.jpeg',
gif: 'http://example.com/animation.gif',
webp: 'http://example.com/modern.webp',
svg: 'http://example.com/vector.svg',
},
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/image.png')
})

test('should handle mixed content including non-URL strings', () => {
const input = {
title: 'My Article',
content: 'This is some text content',
image: 'http://example.com/article.png',
author: 'John Doe',
tags: ['tech', 'programming'],
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/article.png')
})

test('should extract data:image URLs', () => {
const input = {
avatar:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
name: 'User',
}
const result = extractFirstImageUrl(input)
expect(result).toBe(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
)
})

test('should return null when no image URLs found', () => {
const input = {
title: 'Article Title',
content: 'Some text content',
author: 'John Doe',
tags: ['tech', 'programming'],
metadata: {
created: '2023-01-01',
views: 100,
},
}
const result = extractFirstImageUrl(input)
expect(result).toBeNull()
})

test('should handle null input gracefully', () => {
const result = extractFirstImageUrl(null)
expect(result).toBeNull()
})

test('should handle undefined input gracefully', () => {
const result = extractFirstImageUrl(undefined)
expect(result).toBeNull()
})

test('should handle non-object input gracefully', () => {
expect(extractFirstImageUrl('string')).toBeNull()
expect(extractFirstImageUrl(123)).toBeNull()
expect(extractFirstImageUrl(true)).toBeNull()
})

test('should handle URLs with query parameters', () => {
const input = {
thumbnail: 'http://example.com/thumb.jpg?size=small&quality=80',
fullsize: 'http://example.com/full.png?width=1920&height=1080',
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/thumb.jpg?size=small&quality=80')
})

test('should handle deeply nested objects', () => {
const input = {
level1: {
level2: {
level3: {
level4: {
deepImage: 'http://example.com/deep.jpg',
},
},
},
},
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/deep.jpg')
})

test('should handle arrays within nested objects', () => {
const input = {
article: {
content: {
sections: [
{
type: 'text',
value: 'Some text',
},
{
type: 'image',
value: 'http://example.com/section1.png',
},
{
type: 'gallery',
images: [
'http://example.com/gallery1.jpg',
'http://example.com/gallery2.jpg',
],
},
],
},
},
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/section1.png')
})

test('should handle case-insensitive file extensions', () => {
const input = {
mixedCase: 'http://example.com/Photo.JpG',
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/Photo.JpG')
})

test('should respect MAX_DEPTH limit and stop traversal at maximum depth', () => {
const input = {
level1: {
level2: {
level3: {
level4: {
level5: {
level6: {
level7: {
level8: {
level9Image: 'http://example.com/level9.jpg',
level9: {
level10Image: 'http://example.com/level10.jpg',
},
},
},
},
},
},
},
},
},
}
const result = extractFirstImageUrl(input)
expect(result).toBe('http://example.com/level9.jpg')
})
})
Loading
Loading