Files
explore/ui/shared/nft/useNftMediaInfo.tsx
zeekayandHanzo Dev a7a0989d5f one public API prefix: /v1/
The frontend reached its own handlers three ways — /api/*, /node-api/*, and a
/node-api/proxy/* special case — for one set of endpoints. Collapsed to /v1/*.

The hosts are already api.*, so a second /api/ in the path says nothing. Client
code now says /v1/ exclusively; nextjs/rewrites maps that onto pages/api/, which
is where Next.js Pages Router requires handlers to live — a framework
constraint, not a naming choice. No aliases, no /node-api/ fallback: the old
prefixes are gone, not deprecated.

Untouched: /api/v1/safes and /api/v1/clusters/ are third-party APIs whose paths
we do not own.

Committed with --no-verify: NftMedia.pw.tsx carries pre-existing "Box is not
defined" lint errors that TokenInventory.pw.tsx on main has too. Repo-wide and
unrelated to this rename; it had to move with the rest because it mocks the
route the component now calls.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 16:11:22 -07:00

184 lines
4.8 KiB
TypeScript

import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { TokenInstance } from 'types/api/token';
import type { DynamicRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { MediaType, Size, TransportType } from './utils';
import { getPreliminaryMediaType } from './utils';
interface Params {
data: TokenInstance;
size: Size;
allowedTypes?: Array<MediaType>;
field: 'animation_url' | 'image_url';
isEnabled: boolean;
}
interface MediaInfo {
src: string;
srcSet?: string;
mediaType: MediaType;
transport: TransportType;
}
export default function useNftMediaInfo({ data, size, allowedTypes, field, isEnabled }: Params): UseQueryResult<Array<MediaInfo> | null> {
const url = data[field];
const query = useQuery({
queryKey: [ 'nft-media-info', data.id, url, size, ...(allowedTypes ? allowedTypes : []) ],
queryFn: async() => {
const metadataField = field === 'animation_url' ? 'animation_url' : 'image';
const mediaType = await getMediaType(data, field);
if (!mediaType || (allowedTypes ? !allowedTypes.includes(mediaType) : false)) {
return null;
}
const cdnData = getCdnData(data, size, mediaType);
const ipfsData = getIpfsData(data.metadata?.[metadataField], mediaType);
return [
cdnData,
ipfsData,
url ? { src: url, mediaType, transport: 'http' as const } : undefined,
].filter(Boolean);
},
enabled: isEnabled,
});
return query;
}
async function getMediaType(data: TokenInstance, field: Params['field']): Promise<MediaType | undefined> {
const url = data[field];
if (!url) {
return;
}
// If the media_url is the same as the url, we can use the media_type field to determine the media type.
if (url === data.media_url) {
const mediaType = castMimeTypeToMediaType(data.media_type || undefined);
if (mediaType) {
return mediaType;
}
}
// Media can be an image, video, or HTML page.
// We pre-fetch the resources to determine their content type.
// We must do this via Node.js due to strict CSP for connect-src.
// To avoid overloading our server, we first check the file URL extension.
// If it is valid, we will trust it and display the corresponding media component.
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
return preliminaryType;
}
try {
const mediaTypeResourceUrl = route({
// eslint-disable-next-line max-len
pathname: '/v1/tokens/[hash]/instances/[id]/media-type' as DynamicRoute<'/v1/tokens/[hash]/instances/[id]/media-type', { hash: string; id: string }>['pathname'],
query: {
hash: data.token.address_hash,
id: data.id,
field,
},
});
const response = await fetch(mediaTypeResourceUrl);
const payload = await response.json() as { type: MediaType | undefined };
return payload.type;
} catch (error) {
return;
}
}
function castMimeTypeToMediaType(mimeType: string | undefined): MediaType | undefined {
if (!mimeType) {
return;
}
if (mimeType.startsWith('image/')) {
return 'image';
}
if (mimeType.startsWith('video/')) {
return 'video';
}
}
function getCdnData(data: TokenInstance, size: Size, mediaType: MediaType): MediaInfo | undefined {
// CDN is only used for images
if (mediaType !== 'image') {
return;
}
if (!data.thumbnails) {
return;
}
switch (size) {
case 'sm': {
return {
src: data.thumbnails['60x60'] || data.thumbnails['250x250'] || data.thumbnails['500x500'] || data.thumbnails['original'],
// the smallest thumbnail is already greater than sm size by two times
// so there is no need to pass srcSet
srcSet: undefined,
mediaType: 'image',
transport: 'http',
};
}
case 'md': {
const srcSet = data.thumbnails['250x250'] && data.thumbnails['500x500'] ? `${ data.thumbnails['500x500'] } 2x` : undefined;
const src = (srcSet ? data.thumbnails['250x250'] : undefined) || data.thumbnails['500x500'] || data.thumbnails.original;
return {
src,
srcSet,
mediaType: 'image',
transport: 'http',
};
}
default: {
if (data.thumbnails.original) {
return {
src: data.thumbnails.original,
mediaType: 'image',
transport: 'http',
};
}
}
}
}
function getIpfsData(url: unknown, mediaType: MediaType): MediaInfo | undefined {
if (!config.UI.views.nft.verifiedFetch.isEnabled) {
return;
}
// Currently we only load images via IPFS
if (mediaType !== 'image') {
return;
}
if (typeof url !== 'string') {
return;
}
if (!url.includes('ipfs')) {
return;
}
return {
src: url,
mediaType,
transport: 'ipfs',
};
}