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
120 changes: 120 additions & 0 deletions src/app/components/VideoPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Box from '@mui/material/Box'
import Backdrop from '@mui/material/Backdrop'
import Modal from '@mui/material/Modal'
import Fade from '@mui/material/Fade'
import Typography from '@mui/material/Typography'
import CancelIcon from '@mui/icons-material/Cancel'
import PlayCircleFilledWhiteIcon from '@mui/icons-material/PlayCircleFilledWhite'
import IconButton from '@mui/material/IconButton'
import { styled } from '@mui/material/styles'
import { COLORS } from '../../../styles/theme/colors'

const StyledThumbnailContainer = styled(Box)({
position: 'relative',
maxWidth: '100%',
maxHeight: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})

const StyledThumbnail = styled('video', {
shouldForwardProp: prop => prop !== 'maxThumbnailSize',
})<{ maxThumbnailSize: string }>(({ maxThumbnailSize }) => ({
maxWidth: maxThumbnailSize,
maxHeight: maxThumbnailSize,
}))

const PlayButton = styled(IconButton)({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'transparent',
color: COLORS.white,
fontSize: 'inherit',
'& .MuiSvgIcon-root': {
fontSize: '48px',
},
})

const StyledVideo = styled('video')({
maxWidth: '80dvw',
maxHeight: '80dvh',
})

type VideoPreviewProps = {
handlePreviewClose: () => void
handlePreviewOpen: () => void
onError: () => void
previewOpen: boolean
src: string
title: string | undefined
maxThumbnailSize: string
}

export const VideoPreview: FC<VideoPreviewProps> = ({
handlePreviewClose,
handlePreviewOpen,
previewOpen,
onError,
src,
title,
maxThumbnailSize,
}) => {
const { t } = useTranslation()
const label = title || t('nft.videoPreview')

return (
<>
<StyledThumbnailContainer>
<StyledThumbnail onError={onError} src={src} maxThumbnailSize={maxThumbnailSize} />
<PlayButton aria-label="Play" onClick={handlePreviewOpen}>
<PlayCircleFilledWhiteIcon />
</PlayButton>
</StyledThumbnailContainer>
<Modal
aria-labelledby={label}
open={previewOpen}
onClose={handlePreviewClose}
closeAfterTransition
slots={{ backdrop: Backdrop }}
slotProps={{
backdrop: {
timeout: 500,
},
}}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Fade in={previewOpen}>
<Box sx={{ position: 'absolute' }}>
<Box
sx={{
display: 'flex',
justifyContent: title ? 'space-between' : 'flex-end',
alignItems: 'center',
mb: 3,
}}
>
{title && (
<Typography fontSize="24px" fontWeight={700} color={COLORS.white}>
{title}
</Typography>
)}
<IconButton aria-label="Close" onClick={handlePreviewClose}>
<CancelIcon sx={{ fontSize: '40px', color: COLORS.white }} />
</IconButton>
</Box>
<StyledVideo src={src} autoPlay loop />
</Box>
</Fade>
</Modal>
</>
)
}
50 changes: 40 additions & 10 deletions src/app/pages/NFTInstanceDashboardPage/InstanceImageCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Box from '@mui/material/Box'
import { Button } from '@mui/base/Button'
Expand All @@ -15,7 +15,9 @@ import { processNftImageUrl } from '../../utils/nft-images'
import { hasValidProtocol } from '../../utils/url'
import { COLORS } from '../../../styles/theme/colors'
import { ImagePreview } from '../../components/ImagePreview'
import { VideoPreview } from 'app/components/VideoPreview'
import { NoPreview } from '../../components/NoPreview'
import { checkContentType } from 'app/utils/ipfs'

const imageSize = '350px'

Expand Down Expand Up @@ -81,6 +83,21 @@ export const InstanceImageCard: FC<InstanceImageCardProps> = ({ isFetched, isLoa
const handlePreviewOpen = () => setPreviewOpen(true)
const handlePreviewClose = () => setPreviewOpen(false)
const [imageLoadError, setImageLoadError] = useState(false)
const [contentType, setContentType] = useState<string>('')

useEffect(() => {
async function fetchContentType() {
try {
const url = processNftImageUrl(nft?.image)
const type = await checkContentType(url)
setContentType(type)
} catch (error) {
console.error('Error fetching content type:', error)
}
}

fetchContentType()
}, [nft?.image])

return (
<Card
Expand Down Expand Up @@ -130,15 +147,28 @@ export const InstanceImageCard: FC<InstanceImageCardProps> = ({ isFetched, isLoa
alignItems: 'center',
}}
>
<ImagePreview
handlePreviewClose={handlePreviewClose}
handlePreviewOpen={handlePreviewOpen}
previewOpen={previewOpen}
src={processNftImageUrl(nft.image)}
onError={() => setImageLoadError(true)}
title={nft.name}
maxThumbnailSize={imageSize}
/>
{contentType === 'image' && (
<ImagePreview
handlePreviewClose={handlePreviewClose}
handlePreviewOpen={handlePreviewOpen}
previewOpen={previewOpen}
src={processNftImageUrl(nft.image)}
onError={() => setImageLoadError(true)}
title={nft.name}
maxThumbnailSize={imageSize}
/>
)}
{contentType === 'video' && (
<VideoPreview
handlePreviewClose={handlePreviewClose}
handlePreviewOpen={handlePreviewOpen}
previewOpen={previewOpen}
src={processNftImageUrl(nft.image)}
onError={() => setImageLoadError(true)}
title={nft.name}
maxThumbnailSize={imageSize}
/>
)}
</Box>
<Box
sx={{
Expand Down
56 changes: 48 additions & 8 deletions src/app/pages/TokenDashboardPage/ImageListItemImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link as RouterLink } from 'react-router-dom'
import Box from '@mui/material/Box'
Expand All @@ -12,6 +12,7 @@ import { getNftInstanceLabel } from '../../utils/nft'
import { EvmNft } from '../../../oasis-nexus/api'
import { useScreenSize } from 'app/hooks/useScreensize'
import { COLORS } from 'styles/theme/colors'
import { checkContentType } from 'app/utils/ipfs'

const minMobileSize = '150px'
const minSize = '210px'
Expand All @@ -31,6 +32,21 @@ const StyledImage = styled('img', {
},
}))

const StyledVideo = styled('video', {
shouldForwardProp: prop => prop !== 'isMobile',
})<{ isMobile: boolean }>(({ isMobile }) => ({
minWidth: isMobile ? minMobileSize : minSize,
minHeight: isMobile ? minMobileSize : minSize,
width: '100%',
height: '100%',
maxHeight: minSize,
objectFit: 'cover',
transition: 'opacity 250ms ease-in-out',
'&:hover, &:focus-visible': {
opacity: 0.15,
},
}))

const StyledBox = styled(Box)(() => ({
position: 'absolute',
display: 'flex',
Expand Down Expand Up @@ -59,17 +75,41 @@ export const ImageListItemImage: FC<ImageListItemImageProps> = ({ instance, to }
const { t } = useTranslation()
const { isMobile } = useScreenSize()
const [imageLoadError, setImageLoadError] = useState(false)
const [contentType, setContentType] = useState<string>('')

useEffect(() => {
async function fetchContentType() {
try {
const url = processNftImageUrl(instance?.image)
const type = await checkContentType(url)
setContentType(type)
} catch (error) {
console.error('Error fetching content type:', error)
}
}

fetchContentType()
}, [instance?.image])

return (
<Link component={RouterLink} to={to} sx={{ display: 'flex', position: 'relative' }}>
{hasValidProtocol(instance.image) && !imageLoadError ? (
<StyledImage
onError={() => setImageLoadError(true)}
src={processNftImageUrl(instance.image)}
alt={getNftInstanceLabel(instance)}
loading="lazy"
isMobile={isMobile}
/>
(contentType === 'image' && (
<StyledImage
onError={() => setImageLoadError(true)}
src={processNftImageUrl(instance.image)}
alt={getNftInstanceLabel(instance)}
loading="lazy"
isMobile={isMobile}
/>
)) ||
(contentType === 'video' && (
<StyledVideo
onError={() => setImageLoadError(true)}
src={processNftImageUrl(instance.image)}
isMobile={isMobile}
/>
))
) : (
<NoPreview placeholderSize={isMobile ? minMobileSize : minSize} />
)}
Expand Down
22 changes: 22 additions & 0 deletions src/app/utils/ipfs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import axios from 'axios'
import { ipfs } from './externalLinks'

export const ipfsUrlPrefix = 'ipfs://'
Expand All @@ -12,3 +13,24 @@ export const accessIpfsUrl = (url: string): string => {
}
return `${ipfs.proxyPrefix}${url.substring(ipfsUrlPrefix.length)}`
}

/**
* Return content-type of the asset uploaded on the provided link
*/
export const checkContentType = async (url: string) => {
try {
const response = await axios.head(url);
const contentType = response.headers['content-type'];

if (contentType && contentType.startsWith('image')) {
return 'image';
} else if (contentType && contentType.startsWith('video')) {
return 'video';
} else {
return 'unknown';
}
} catch (error) {
console.error("Error fetching NFT resource:", error);
return 'error';
}
}
1 change: 1 addition & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
"instanceTokenId": "Token ID",
"instanceTitleSuffix": "(NFT Instance)",
"imagePreview": "Image preview",
"videoPreview": "Video preview",
"metadata": "Metadata",
"noMetadata": "There is no metadata on record for this NFT.",
"noPreview": "No preview available",
Expand Down