solana/explorer/src/components/common/NFTArt.tsx

334 lines
8.5 KiB
TypeScript

import { useEffect, useState } from "react";
import { Stream } from "@cloudflare/stream-react";
import { PublicKey } from "@solana/web3.js";
import {
programs,
MetadataJson,
MetaDataJsonCategory,
MetadataJsonFile,
} from "@metaplex/js";
import ContentLoader from "react-content-loader";
import ErrorLogo from "img/logos-solana/dark-solana-logo.svg";
import { getLast } from "utils";
export const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */
const LoadingPlaceholder = () => (
<ContentLoader
viewBox="0 0 212 200"
height={150}
width={150}
backgroundColor="transparent"
>
<circle cx="86" cy="100" r="8" />
<circle cx="106" cy="100" r="8" />
<circle cx="126" cy="100" r="8" />
</ContentLoader>
);
const ErrorPlaceHolder = () => (
<img src={ErrorLogo} width="120" height="120" alt="Solana Logo" />
);
const ViewOriginalArtContentLink = ({ src }: { src: string }) => {
if (!src) {
return null;
}
return (
<h6 className={"header-pretitle d-flex justify-content-center mt-2"}>
<a href={src}>VIEW ORIGINAL</a>
</h6>
);
};
export const CachedImageContent = ({ uri }: { uri?: string }) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<boolean>(false);
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
// Set the timeout if we don't have a valid uri
if (!uri && !timeout) {
setTimeout(setInterval(() => setShowError(true), MAX_TIME_LOADING_IMAGE));
}
// We have a uri - clear the timeout
if (uri && timeout) {
clearInterval(timeout);
}
return () => {
if (timeout) {
clearInterval(timeout);
}
};
}, [uri, setShowError, timeout, setTimeout]);
const { cachedBlob } = useCachedImage(uri || "");
return (
<>
{showError ? (
<div className={"art-error-image-placeholder"}>
<ErrorPlaceHolder />
<h6 className={"header-pretitle mt-2"}>Error Loading Image</h6>
</div>
) : (
<>
{isLoading && <LoadingPlaceholder />}
<div className={`${isLoading ? "d-none" : "d-block"}`}>
<img
className={`rounded mx-auto ${isLoading ? "d-none" : "d-block"}`}
src={cachedBlob}
alt={"nft"}
style={{
width: 150,
maxHeight: 200,
}}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setShowError(true);
}}
/>
{uri && <ViewOriginalArtContentLink src={uri} />}
</div>
</>
)}
</>
);
};
const VideoArtContent = ({
files,
uri,
animationURL,
}: {
files?: (MetadataJsonFile | string)[];
uri?: string;
animationURL?: string;
}) => {
const likelyVideo = (files || []).filter((f, index, arr) => {
if (typeof f !== "string") {
return false;
}
// TODO: filter by fileType
return arr.length >= 2 ? index === 1 : index === 0;
})?.[0] as string;
const content =
likelyVideo &&
likelyVideo.startsWith("https://watch.videodelivery.net/") ? (
<div className={"d-block"}>
<Stream
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
loop={true}
height={180}
width={320}
controls={false}
style={{ borderRadius: 12 }}
videoDimensions={{
videoWidth: 320,
videoHeight: 180,
}}
autoplay={true}
muted={true}
/>
<ViewOriginalArtContentLink
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
/>
</div>
) : (
<div className={"d-block"}>
<video
playsInline={true}
autoPlay={true}
muted={true}
controls={true}
controlsList="nodownload"
style={{ borderRadius: 12, width: 320, height: 180 }}
loop={true}
poster={uri}
>
{likelyVideo && <source src={likelyVideo} type="video/mp4" />}
{animationURL && <source src={animationURL} type="video/mp4" />}
{files
?.filter((f) => typeof f !== "string")
.map((f: any, index: number) => (
<source key={index} src={f.uri} type={f.type} />
))}
</video>
{(likelyVideo || animationURL) && (
<ViewOriginalArtContentLink src={(likelyVideo || animationURL)!} />
)}
</div>
);
return content;
};
const HTMLContent = ({
animationUrl,
files,
}: {
animationUrl?: string;
files?: (MetadataJsonFile | string)[];
}) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showError, setShowError] = useState<boolean>(false);
const htmlURL =
files && files.length > 0 && typeof files[0] === "string"
? files[0]
: animationUrl;
return (
<>
{showError ? (
<div className={"art-error-image-placeholder"}>
<ErrorPlaceHolder />
<h6 className={"header-pretitle mt-2"}>Error Loading Image</h6>
</div>
) : (
<>
{!isLoading && <LoadingPlaceholder />}
<div className={`${isLoading ? "d-block" : "d-none"}`}>
<iframe
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
title={"html-content"}
sandbox="allow-scripts"
frameBorder="0"
src={htmlURL}
style={{ width: 320, height: 180, borderRadius: 12 }}
onLoad={() => {
setIsLoading(true);
}}
onError={() => {
setShowError(true);
}}
></iframe>
{htmlURL && <ViewOriginalArtContentLink src={htmlURL} />}
</div>
</>
)}
</>
);
};
export const ArtContent = ({
metadata,
category,
pubkey,
uri,
animationURL,
files,
data,
}: {
metadata: programs.metadata.MetadataData;
category?: MetaDataJsonCategory;
pubkey?: PublicKey | string;
uri?: string;
animationURL?: string;
files?: (MetadataJsonFile | string)[];
data: MetadataJson | undefined;
}) => {
if (pubkey && data) {
uri = data.image;
animationURL = data.animation_url;
}
if (pubkey && data?.properties) {
files = data.properties.files;
category = data.properties.category;
}
animationURL = animationURL || "";
const animationUrlExt = new URLSearchParams(
getLast(animationURL.split("?"))
).get("ext");
const content =
category === "video" ? (
<VideoArtContent files={files} uri={uri} animationURL={animationURL} />
) : category === "html" || animationUrlExt === "html" ? (
<HTMLContent animationUrl={animationURL} files={files} />
) : (
<CachedImageContent uri={uri} />
);
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{content}
</div>
);
};
enum ArtFetchStatus {
ReadyToFetch,
Fetching,
FetchFailed,
FetchSucceeded,
}
const cachedImages = new Map<string, string>();
export const useCachedImage = (uri: string) => {
const [cachedBlob, setCachedBlob] = useState<string | undefined>(undefined);
const [fetchStatus, setFetchStatus] = useState<ArtFetchStatus>(
ArtFetchStatus.ReadyToFetch
);
useEffect(() => {
if (!uri) {
return;
}
if (fetchStatus === ArtFetchStatus.FetchFailed) {
setCachedBlob(uri);
return;
}
const result = cachedImages.get(uri);
if (result) {
setCachedBlob(result);
return;
}
if (fetchStatus === ArtFetchStatus.ReadyToFetch) {
(async () => {
setFetchStatus(ArtFetchStatus.Fetching);
let response: Response;
try {
response = await fetch(uri, { cache: "force-cache" });
} catch {
try {
response = await fetch(uri, { cache: "reload" });
} catch {
if (uri?.startsWith("http")) {
setCachedBlob(uri);
}
setFetchStatus(ArtFetchStatus.FetchFailed);
return;
}
}
const blob = await response.blob();
const blobURI = URL.createObjectURL(blob);
cachedImages.set(uri, blobURI);
setCachedBlob(blobURI);
setFetchStatus(ArtFetchStatus.FetchSucceeded);
})();
}
}, [uri, setCachedBlob, fetchStatus, setFetchStatus]);
return { cachedBlob };
};