diff --git a/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx b/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx index 05d88ca192..1d284052bf 100644 --- a/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx +++ b/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx @@ -1,5 +1,9 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +const mockGetArweaveGatewayFallbackUrls = jest.fn(); +const mockShouldUseIframeFallbackTimeout = jest.fn(); +const mockSandboxedExternalIframe = jest.fn(); jest.mock( "@/components/drops/view/item/content/media/MediaDisplayImage", @@ -26,15 +30,18 @@ jest.mock( /> ) ); -jest.mock("@/components/common/SandboxedExternalIframe", () => (props: any) => ( -
-)); +jest.mock("@/components/common/SandboxedExternalIframe", () => (props: any) => { + mockSandboxedExternalIframe(props); + return ( +
+ ); +}); jest.mock( "@/components/drops/media/InteractiveMediaLoadGate", () => (props: any) => ( @@ -52,9 +59,28 @@ jest.mock( : () =>
); +jest.mock("@/components/nft-image/utils/gateway-fallback", () => ({ + getArweaveGatewayFallbackUrls: (...args: unknown[]) => + mockGetArweaveGatewayFallbackUrls(...args), + shouldUseIframeFallbackTimeout: (...args: unknown[]) => + mockShouldUseIframeFallbackTimeout(...args), +})); + import MediaDisplay from "@/components/drops/view/item/content/media/MediaDisplay"; describe("MediaDisplay", () => { + beforeEach(() => { + jest.useRealTimers(); + mockSandboxedExternalIframe.mockClear(); + mockGetArweaveGatewayFallbackUrls.mockImplementation((url: string) => { + if (url === "ipfs://hash") { + return ["https://ipfs.io/ipfs/hash"]; + } + return [url]; + }); + mockShouldUseIframeFallbackTimeout.mockReturnValue(true); + }); + it("renders image", () => { render(); expect(screen.getByTestId("image")).toHaveAttribute("data-src", "img.png"); @@ -182,4 +208,54 @@ describe("MediaDisplay", () => { ); expect(container).toBeEmptyDOMElement(); }); + + it("does not auto-advance ipfs html if the timeout fallback is disabled", () => { + jest.useFakeTimers(); + mockGetArweaveGatewayFallbackUrls.mockReturnValue([ + "https://ipfs.6529.io/ipfs/hash", + "https://ipfs.io/ipfs/hash", + ]); + mockShouldUseIframeFallbackTimeout.mockReturnValue(false); + + render( + + ); + + act(() => { + jest.advanceTimersByTime(8000); + }); + + expect(screen.getByTestId("iframe")).toHaveAttribute( + "data-src", + "https://ipfs.6529.io/ipfs/hash" + ); + }); + + it("auto-advances html when timeout fallback is enabled", () => { + jest.useFakeTimers(); + mockGetArweaveGatewayFallbackUrls.mockReturnValue([ + "https://arweave.net/tx", + "https://ardrive.net/tx", + ]); + mockShouldUseIframeFallbackTimeout.mockReturnValue(true); + + render( + + ); + + act(() => { + jest.advanceTimersByTime(8000); + }); + + expect(screen.getByTestId("iframe")).toHaveAttribute( + "data-src", + "https://ardrive.net/tx" + ); + }); }); diff --git a/__tests__/components/memelab/MemeLabPage.test.tsx b/__tests__/components/memelab/MemeLabPage.test.tsx index fb3f5853cd..6dbdcc9751 100644 --- a/__tests__/components/memelab/MemeLabPage.test.tsx +++ b/__tests__/components/memelab/MemeLabPage.test.tsx @@ -638,6 +638,35 @@ describe("MemeLabPageComponent", () => { }); }); + it("does not crash on live tab when API media fields are null", async () => { + mockUseSearchParams.mockReturnValue({ + get: jest.fn((key: string) => { + if (key === "focus") return MEME_FOCUS.LIVE; + return null; + }), + }); + + setupMockApiCalls(1, { + uri: null, + image: null, + animation: null, + metadata: { + attributes: [], + image: null, + animation: null, + animation_url: null, + }, + }); + + await act(async () => { + renderWithQueryClient(); + }); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "NFT" })).toBeInTheDocument(); + }); + }); + it("treats metadata.animation_url-only NFTs as animated", async () => { mockUseSearchParams.mockReturnValue({ get: jest.fn((key: string) => { @@ -674,7 +703,7 @@ describe("MemeLabPageComponent", () => { expect( screen.getByRole("link", { name: "Open animation in new tab" }) ).toHaveAttribute("href", "https://metadata.example/animation.html"); - expect(getCardDetailValue("File Type")).toBe("HTML"); + expect(getCardDetailValue("File Type")).toBe("Interactive - HTML"); expect(getCardDetailValue("Dimensions")).toBe("1,920 x 1,080"); }); }); @@ -709,7 +738,7 @@ describe("MemeLabPageComponent", () => { fireEvent.click(screen.getByTestId("carousel-slide-1")); await waitFor(() => { - expect(getCardDetailValue("File Type")).toBe("PNG"); + expect(getCardDetailValue("File Type")).toBe("Image - PNG"); expect(getCardDetailValue("Dimensions")).toBe("1,200 x 800"); }); }); diff --git a/__tests__/components/nft-image/utils/gateway-fallback.test.ts b/__tests__/components/nft-image/utils/gateway-fallback.test.ts new file mode 100644 index 0000000000..691fc63279 --- /dev/null +++ b/__tests__/components/nft-image/utils/gateway-fallback.test.ts @@ -0,0 +1,73 @@ +jest.mock("@/components/ipfs/IPFSContext", () => ({ + resolveIpfsUrlSync: (url: string) => + url.startsWith("ipfs://") + ? `https://ipfs.6529.io/ipfs/${url.slice("ipfs://".length)}` + : url, +})); + +jest.mock("@/helpers/Helpers", () => ({ + parseIpfsUrl: (url: string) => + url.startsWith("ipfs://") + ? `https://ipfs.io/ipfs/${url.slice("ipfs://".length)}` + : url, +})); + +jest.mock("@/lib/media/ipfs-gateways", () => ({ + getConfiguredIpfsGatewayHost: () => "ipfs.6529.io", +})); + +jest.mock("@/lib/media/arweave-gateways", () => ({ + ARWEAVE_FALLBACK_HOSTS: ["arweave.net", "ardrive.net", "gateway.arweave.net"], + canonicalizeArweaveGatewayHostname: (hostname: string) => + hostname.toLowerCase(), + isArweaveGatewayRuntimeHost: (hostname: string) => + ["arweave.net", "ardrive.net", "gateway.arweave.net"].includes( + hostname.toLowerCase() + ), +})); + +import { + getArweaveGatewayFallbackUrls, + shouldUseIframeFallbackTimeout, +} from "@/components/nft-image/utils/gateway-fallback"; + +describe("gateway fallback helpers", () => { + it("prefers the configured gateway for ipfs protocol urls", () => { + expect(getArweaveGatewayFallbackUrls("ipfs://bafy-test")).toEqual([ + "https://ipfs.6529.io/ipfs/bafy-test", + "https://ipfs.io/ipfs/bafy-test", + ]); + }); + + it("normalizes ipfs gateway urls back to configured gateway first", () => { + expect( + getArweaveGatewayFallbackUrls("https://ipfs.io/ipfs/bafy-test") + ).toEqual([ + "https://ipfs.6529.io/ipfs/bafy-test", + "https://ipfs.io/ipfs/bafy-test", + ]); + }); + + it("uses timeout fallback for approved arweave gateways", () => { + expect( + shouldUseIframeFallbackTimeout("https://arweave.net/transaction-id") + ).toBe(true); + }); + + it("does not use timeout fallback for empty urls", () => { + expect(shouldUseIframeFallbackTimeout("")).toBe(false); + }); + + it("does not use timeout fallback for ipfs protocol urls", () => { + expect(shouldUseIframeFallbackTimeout("ipfs://bafy-test")).toBe(false); + }); + + it("does not use timeout fallback for ipfs gateways", () => { + expect( + shouldUseIframeFallbackTimeout("https://ipfs.6529.io/ipfs/bafy-test") + ).toBe(false); + expect( + shouldUseIframeFallbackTimeout("https://ipfs.io/ipfs/bafy-test") + ).toBe(false); + }); +}); diff --git a/__tests__/components/the-memes/MemePageArt.test.tsx b/__tests__/components/the-memes/MemePageArt.test.tsx index 1b65dcb50c..f46ba54d74 100644 --- a/__tests__/components/the-memes/MemePageArt.test.tsx +++ b/__tests__/components/the-memes/MemePageArt.test.tsx @@ -69,8 +69,10 @@ jest.mock("@/helpers/Helpers", () => ({ jest.mock("@/helpers/nft.helpers", () => ({ getAnimationDimensionsFromMetadata: jest.fn(), getAnimationFileTypeFromMetadata: jest.fn(), + getAnimationMimeTypeFromMetadata: jest.fn(), getImageDimensionsFromMetadata: jest.fn(), getImageFileTypeFromMetadata: jest.fn(), + getImageMimeTypeFromMetadata: jest.fn(), })); jest.mock("@/components/nft-attributes/NFTAttributes", () => ({ __esModule: true, @@ -86,14 +88,17 @@ const mockHelpers = jest.requireMock("@/helpers/Helpers") as { const mockNftHelpers = jest.requireMock("@/helpers/nft.helpers") as { getAnimationDimensionsFromMetadata: jest.Mock; getAnimationFileTypeFromMetadata: jest.Mock; + getAnimationMimeTypeFromMetadata: jest.Mock; getImageDimensionsFromMetadata: jest.Mock; getImageFileTypeFromMetadata: jest.Mock; + getImageMimeTypeFromMetadata: jest.Mock; }; const nft = { id: 5, has_distribution: false, mint_price: 1, + hodl_rate: 24.48, supply: 10, collection: "c", artist: "a", @@ -116,9 +121,9 @@ const nft = { const nftMeta = { season: 1, meme_name: "meme" }; const getCardDetailValue = (label: string): string => { - const labelCell = screen.getByText((_, element) => { + const labelCell = screen.getAllByText((_, element) => { return element?.textContent?.toLowerCase() === label.toLowerCase(); - }); + })[0]; const row = labelCell.closest("tr"); const value = row?.querySelectorAll("td")[1]?.textContent; @@ -129,7 +134,9 @@ const getCardDetailValue = (label: string): string => { beforeEach(() => { jest.clearAllMocks(); mockNftHelpers.getAnimationFileTypeFromMetadata.mockReturnValue("gif"); + mockNftHelpers.getAnimationMimeTypeFromMetadata.mockReturnValue("image/gif"); mockNftHelpers.getImageFileTypeFromMetadata.mockReturnValue("png"); + mockNftHelpers.getImageMimeTypeFromMetadata.mockReturnValue("image/png"); mockNftHelpers.getAnimationDimensionsFromMetadata.mockReturnValue("200x300"); mockNftHelpers.getImageDimensionsFromMetadata.mockReturnValue("100x100"); }); @@ -159,12 +166,13 @@ describe("MemePageArt", () => { screen.getByRole("link", { name: "Open image in new tab" }) ).toHaveAttribute("href", "img"); expect(screen.getByText("Card Details")).toBeInTheDocument(); - expect(screen.getByText("Minting Approach")).toBeInTheDocument(); + expect(screen.getAllByText("Minting Approach").length).toBeGreaterThan(0); expect(screen.getByText("Card Description")).toBeInTheDocument(); expect(screen.getByText("Properties")).toBeInTheDocument(); expect(screen.getByText("Stats")).toBeInTheDocument(); expect(screen.getByText("Boosts")).toBeInTheDocument(); - expect(getCardDetailValue("File type")).toBe("png"); + expect(getCardDetailValue("File type")).toBe("Image - PNG"); + expect(getCardDetailValue("TDH Rate")).toBe("24.48"); expect(getCardDetailValue("Dimensions")).toBe("100x100"); }); @@ -347,7 +355,7 @@ describe("MemePageArt", () => { expect( screen.getByRole("link", { name: "Open animation in new tab" }) ).toHaveAttribute("href", "https://metadata.example/animation.gif"); - expect(getCardDetailValue("File type")).toBe("gif"); + expect(getCardDetailValue("File type")).toBe("Image - GIF"); expect(getCardDetailValue("Dimensions")).toBe("200x300"); }); @@ -374,7 +382,7 @@ describe("MemePageArt", () => { fireEvent.click(screen.getByTestId("carousel-slide-1")); - expect(getCardDetailValue("File type")).toBe("png"); + expect(getCardDetailValue("File type")).toBe("Image - PNG"); expect(getCardDetailValue("Dimensions")).toBe("100x100"); }); @@ -401,13 +409,13 @@ describe("MemePageArt", () => { ); expect(screen.getAllByTestId("nft")).toHaveLength(1); - expect(getCardDetailValue("File type")).toBe("gif"); + expect(getCardDetailValue("File type")).toBe("Image - GIF"); expect(getCardDetailValue("Dimensions")).toBe("200x300"); fireEvent.click(screen.getByTestId("carousel-slide-1")); expect(screen.getAllByTestId("nft")).toHaveLength(1); - expect(getCardDetailValue("File type")).toBe("gif"); + expect(getCardDetailValue("File type")).toBe("Image - GIF"); expect(getCardDetailValue("Dimensions")).toBe("200x300"); fireEvent.click(screen.getAllByTestId("fullscreen-icon")[0]); @@ -416,4 +424,38 @@ describe("MemePageArt", () => { "the-art-fullscreen-animation" ); }); + + it("shows N/A dimensions for html art without dimension metadata", () => { + mockNftHelpers.getAnimationFileTypeFromMetadata.mockReturnValue("html"); + mockNftHelpers.getAnimationMimeTypeFromMetadata.mockReturnValue( + "text/html" + ); + mockNftHelpers.getAnimationDimensionsFromMetadata.mockReturnValue( + undefined + ); + + const nftWithHtmlAnimation = { + ...nft, + image: "", + animation: "", + metadata: { + image_details: undefined, + animation_details: { format: "html" }, + animation_url: "https://metadata.example/animation.html", + attributes: nft.metadata.attributes, + image: "", + }, + }; + + render( + + ); + + expect(getCardDetailValue("File type")).toBe("Interactive - HTML"); + expect(getCardDetailValue("Dimensions")).toBe("N/A"); + }); }); diff --git a/__tests__/components/the-memes/MemePageLive.test.tsx b/__tests__/components/the-memes/MemePageLive.test.tsx index c838e999d2..eb0221b56b 100644 --- a/__tests__/components/the-memes/MemePageLive.test.tsx +++ b/__tests__/components/the-memes/MemePageLive.test.tsx @@ -32,7 +32,16 @@ jest.mock("@/components/nft-image/RememeImage", () => ({ })); jest.mock("@/components/nft-attributes/NftStats", () => ({ __esModule: true, - NftPageStats: () => , + NftPageStats: ({ afterMetadata }: { afterMetadata?: React.ReactNode }) => ( + <> + + Metadata + View + + {afterMetadata} + + + ), })); const mockFetchUrl = jest.fn(); @@ -232,4 +241,30 @@ describe("MemePageLiveRightMenu distribution link", () => { const link = screen.getByRole("link", { name: /distribution plan/i }); expect(link).toHaveAttribute("href", `/the-memes/5/distribution`); }); + + it("renders the media type row below metadata", async () => { + const nft = createNft({ + metadata: { + animation_details: { + format: "HTML", + }, + }, + }); + const meta = createMeta(); + + await waitFor(() => { + render( + + + + ); + }); + + const metadataCell = screen.getByText("Metadata"); + const metadataRow = metadataCell.closest("tr"); + const fileTypeRow = metadataRow?.nextElementSibling; + + expect(fileTypeRow?.textContent).toContain("File Type"); + expect(fileTypeRow?.textContent).toContain("Interactive - HTML"); + }); }); diff --git a/__tests__/helpers/mint-visibility.helpers.test.ts b/__tests__/helpers/mint-visibility.helpers.test.ts new file mode 100644 index 0000000000..5833fe3c28 --- /dev/null +++ b/__tests__/helpers/mint-visibility.helpers.test.ts @@ -0,0 +1,66 @@ +import { + shouldShowNextMintInLatestDrop, + shouldShowNextWinnerInComingUp, +} from "@/helpers/mint-visibility.helpers"; + +describe("mint visibility helpers", () => { + it("shows latest drop next mint only when the overall mint is complete", () => { + expect( + shouldShowNextMintInLatestDrop({ + isMintEnded: false, + nextMintExists: false, + }) + ).toBe(false); + + expect( + shouldShowNextMintInLatestDrop({ + isMintEnded: true, + nextMintExists: false, + }) + ).toBe(false); + + expect( + shouldShowNextMintInLatestDrop({ + isMintEnded: false, + nextMintExists: true, + }) + ).toBe(false); + + expect( + shouldShowNextMintInLatestDrop({ + isMintEnded: true, + nextMintExists: true, + }) + ).toBe(true); + }); + + it("keeps coming-up next mint visible while the overall mint is not complete", () => { + expect( + shouldShowNextWinnerInComingUp({ + isMintEnded: false, + nextMintExists: false, + }) + ).toBe(false); + + expect( + shouldShowNextWinnerInComingUp({ + isMintEnded: true, + nextMintExists: false, + }) + ).toBe(false); + + expect( + shouldShowNextWinnerInComingUp({ + isMintEnded: false, + nextMintExists: true, + }) + ).toBe(true); + + expect( + shouldShowNextWinnerInComingUp({ + isMintEnded: true, + nextMintExists: true, + }) + ).toBe(false); + }); +}); diff --git a/__tests__/helpers/nft.helpers.test.ts b/__tests__/helpers/nft.helpers.test.ts index 7d999d1869..bc2f64c7ac 100644 --- a/__tests__/helpers/nft.helpers.test.ts +++ b/__tests__/helpers/nft.helpers.test.ts @@ -1,10 +1,15 @@ import { getAnimationDimensionsFromMetadata, getAnimationFileTypeFromMetadata, + getAnimationMimeTypeFromMetadata, getDimensionsFromMetadata, getFileTypeFromMetadata, + getFileMimeTypeFromMetadata, getImageDimensionsFromMetadata, getImageFileTypeFromMetadata, + getImageMimeTypeFromMetadata, + getMimeTypeFromFormat, + getNftMimeType, } from "@/helpers/nft.helpers"; describe("nft.helpers", () => { @@ -36,10 +41,57 @@ describe("nft.helpers", () => { expect(getAnimationFileTypeFromMetadata(metadata)).toBe("MP4"); expect(getImageFileTypeFromMetadata(metadata)).toBe("PNG"); + expect(getAnimationMimeTypeFromMetadata(metadata)).toBe("video/mp4"); + expect(getImageMimeTypeFromMetadata(metadata)).toBe("image/png"); expect(getAnimationDimensionsFromMetadata(metadata)).toBe("1,920 x 1,080"); expect(getImageDimensionsFromMetadata(metadata)).toBe("1,200 x 800"); }); + it("maps known formats to mime types", () => { + expect(getMimeTypeFromFormat(" html ")).toBe("text/html"); + expect(getMimeTypeFromFormat("GLB")).toBe("model/gltf-binary"); + expect(getMimeTypeFromFormat("jpg")).toBe("image/jpeg"); + expect(getMimeTypeFromFormat("unknown")).toBeNull(); + }); + + it("falls back to resolved media URLs when metadata formats are missing", () => { + expect( + getNftMimeType({ + image: "https://example.com/image.webp", + animation: "", + metadata: {}, + } as any) + ).toBe("image/webp"); + + expect( + getNftMimeType({ + image: "https://example.com/image.png", + animation: "https://example.com/scene.glb", + metadata: {}, + } as any) + ).toBe("model/gltf-binary"); + }); + + it("falls back to a generic category mime when a media URL exists without an extension", () => { + expect( + getNftMimeType({ + image: "https://example.com/render", + animation: "", + metadata: {}, + } as any) + ).toBe("image/jpeg"); + }); + + it("keeps animation precedence when the animation url has no recognized extension", () => { + expect( + getNftMimeType({ + image: "https://example.com/image.png", + animation: "https://example.com/animation", + metadata: {}, + } as any) + ).toBe("video/mp4"); + }); + it("returns null for empty, whitespace-only, and non-string formats", () => { expect( getAnimationFileTypeFromMetadata({ @@ -68,6 +120,7 @@ describe("nft.helpers", () => { it("returns null when no usable media metadata is present", () => { expect(getFileTypeFromMetadata({})).toBeNull(); + expect(getFileMimeTypeFromMetadata({})).toBeNull(); expect(getDimensionsFromMetadata({})).toBeNull(); }); diff --git a/components/drops/media/MediaTypeBadge.tsx b/components/drops/media/MediaTypeBadge.tsx index 031f25f6d3..2793d75ead 100644 --- a/components/drops/media/MediaTypeBadge.tsx +++ b/components/drops/media/MediaTypeBadge.tsx @@ -21,8 +21,14 @@ const FORMAT_ICONS: Record = { interface MediaTypeBadgeProps { readonly mimeType: string | undefined; - readonly dropId: string; + readonly dropId?: string; readonly size?: "sm" | "md" | "lg"; + readonly showTooltip?: boolean; + readonly showLabel?: boolean; + readonly tone?: "muted" | "color"; + readonly className?: string; + readonly iconClassName?: string; + readonly labelClassName?: string; } const SIZE_CLASSES = { @@ -41,49 +47,90 @@ export default function MediaTypeBadge({ mimeType, dropId, size = "sm", + showTooltip = true, + showLabel = false, + tone = "muted", + className = "", + iconClassName = "", + labelClassName = "", }: MediaTypeBadgeProps) { const mediaInfo = getMediaTypeInfo(mimeType); - const tooltipId = `format-badge-${dropId}`; + const tooltipId = dropId ? `format-badge-${dropId}` : undefined; + const shouldRenderTooltip = showTooltip && Boolean(tooltipId); const { isOpen, setIsOpen, triggerProps } = useControlledTooltip(); + const iconClasses = [ + "tw-flex", + "tw-items-center", + "tw-justify-center", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-transition-all", + "tw-duration-300", + SIZE_CLASSES[size], + ]; - return ( -
-
+ - - - -
- + + +
+ ); + + return ( +
+ {icon} + {showLabel && ( + {mediaInfo.label} + )} + {shouldRenderTooltip && tooltipId && ( + + )}
); } diff --git a/components/drops/view/item/content/media/MediaDisplay.tsx b/components/drops/view/item/content/media/MediaDisplay.tsx index 3f15ea6d2f..e31487b540 100644 --- a/components/drops/view/item/content/media/MediaDisplay.tsx +++ b/components/drops/view/item/content/media/MediaDisplay.tsx @@ -6,7 +6,10 @@ import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe"; -import { getArweaveGatewayFallbackUrls } from "@/components/nft-image/utils/gateway-fallback"; +import { + getArweaveGatewayFallbackUrls, + shouldUseIframeFallbackTimeout, +} from "@/components/nft-image/utils/gateway-fallback"; import { ImageScale } from "@/helpers/image.helpers"; import MediaDisplayAudio from "./MediaDisplayAudio"; import MediaDisplayImage from "./MediaDisplayImage"; @@ -65,7 +68,12 @@ function InteractiveHtmlMediaDisplay({ }, [activeUrl]); useEffect(() => { - if (!activeUrl || didLoadCurrentUrl || activeIndex + 1 >= urls.length) { + if ( + !activeUrl || + didLoadCurrentUrl || + activeIndex + 1 >= urls.length || + !shouldUseIframeFallbackTimeout(activeUrl) + ) { return; } diff --git a/components/home/next-mint-leading/NextMintLeadingSection.tsx b/components/home/next-mint-leading/NextMintLeadingSection.tsx index 27ff196465..d520620123 100644 --- a/components/home/next-mint-leading/NextMintLeadingSection.tsx +++ b/components/home/next-mint-leading/NextMintLeadingSection.tsx @@ -7,7 +7,6 @@ import { WaveDropsLeaderboardSort, } from "@/hooks/useWaveDropsLeaderboard"; import { getWaveRoute } from "@/helpers/navigation.helpers"; -import { ManifoldClaimStatus } from "@/hooks/useManifoldClaim"; import { shouldShowNextWinnerInComingUp } from "@/helpers/mint-visibility.helpers"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; import Link from "next/link"; @@ -24,7 +23,7 @@ export function NextMintLeadingSection() { const { nft: nowMinting, isFetching: isNowMintingFetching, - status: nowMintingStatus, + isDropComplete: isNowMintingComplete, } = useNowMintingStatus(); const { nextMint, @@ -49,7 +48,7 @@ export function NextMintLeadingSection() { // Determine what to show const canShowNextMint = shouldShowNextWinnerInComingUp({ - isMintEnded: nowMintingStatus === ManifoldClaimStatus.ENDED, + isMintEnded: isNowMintingComplete, nextMintExists: !!nextMint, }); const showNextMint = diff --git a/components/home/now-minting/LatestDropNextMintSection.tsx b/components/home/now-minting/LatestDropNextMintSection.tsx index 9c3882e90a..102fdc5449 100644 --- a/components/home/now-minting/LatestDropNextMintSection.tsx +++ b/components/home/now-minting/LatestDropNextMintSection.tsx @@ -1,9 +1,9 @@ "use client"; -import ProfileAvatar, { - ProfileBadgeSize, -} from "@/components/common/profile/ProfileAvatar"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; +import { resolveIpfsUrl } from "@/components/ipfs/IPFSContext"; import { + getCanonicalNextMintNumber, formatFullDateTime, getNextMintStart, } from "@/components/meme-calendar/meme-calendar.helpers"; @@ -21,6 +21,48 @@ interface LatestDropNextMintSectionProps { readonly drop: ApiDrop; } +function NextMintArtistPill({ + pfp, + label, + href, +}: { + readonly pfp: string | null | undefined; + readonly label: string; + readonly href?: string | undefined; +}) { + const content = ( + + {pfp ? ( + {label} + ) : ( + + ); + + if (!href) { + return content; + } + + return ( + + {content} + + ); +} + const formatDropTimestamp = (timestamp: number): string | null => { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) { @@ -60,8 +102,11 @@ export default function LatestDropNextMintSection({ const submittedAt = formatDropTimestamp(drop.created_at); const description = drop.metadata.find((m) => m.data_key === "description")?.data_value ?? null; - const nextMintStart = getNextMintStart(); - const nextMintLabel = formatFullDateTime(nextMintStart, "local"); + const now = new Date(); + const nextMintStart = getNextMintStart(now); + const nextMintCardNumber = getCanonicalNextMintNumber(now); + const nextMintDateTime = formatFullDateTime(nextMintStart, "local"); + const nextMintLabel = `Card #${nextMintCardNumber} - ${nextMintDateTime}`; return (
@@ -100,10 +145,12 @@ export default function LatestDropNextMintSection({
- - - NEXT MINT - +
+ + + NEXT MINT + +
{nextMintLabel} @@ -122,32 +169,20 @@ export default function LatestDropNextMintSection({

)} - {authorHandle ? ( - - - - {authorName} - - - ) : ( -
- + {media?.mime_type && ( + - - {authorName} - -
- )} + )} + +
diff --git a/components/home/now-minting/LatestDropSection.tsx b/components/home/now-minting/LatestDropSection.tsx index 50cf23935b..724f961818 100644 --- a/components/home/now-minting/LatestDropSection.tsx +++ b/components/home/now-minting/LatestDropSection.tsx @@ -1,6 +1,5 @@ "use client"; -import { ManifoldClaimStatus } from "@/hooks/useManifoldClaim"; import { useNextMintDrop } from "@/hooks/useNextMintDrop"; import { useNowMintingStatus } from "@/hooks/useNowMintingStatus"; import { shouldShowNextMintInLatestDrop } from "@/helpers/mint-visibility.helpers"; @@ -8,7 +7,8 @@ import LatestDropNextMintSection from "./LatestDropNextMintSection"; import NowMintingSection from "./NowMintingSection"; export default function LatestDropSection() { - const { nft, isFetching, status, isStatusLoading } = useNowMintingStatus(); + const { nft, isFetching, isDropComplete, isStatusLoading } = + useNowMintingStatus(); const { nextMint, waveId, @@ -24,7 +24,7 @@ export default function LatestDropSection() { } const shouldShowNextMint = shouldShowNextMintInLatestDrop({ - isMintEnded: status === ManifoldClaimStatus.ENDED, + isMintEnded: isDropComplete, nextMintExists: !!nextMint, }); diff --git a/components/home/now-minting/NowMintingDetails.tsx b/components/home/now-minting/NowMintingDetails.tsx index 4d001f21d9..a630bbad3f 100644 --- a/components/home/now-minting/NowMintingDetails.tsx +++ b/components/home/now-minting/NowMintingDetails.tsx @@ -3,6 +3,7 @@ import { MEMES_CONTRACT } from "@/constants/constants"; import type { NFTWithMemesExtendedData } from "@/entities/INFT"; import { getDimensionsFromMetadata, + getFileMimeTypeFromMetadata, getFileTypeFromMetadata, } from "@/helpers/nft.helpers"; import NowMintingCountdown from "./NowMintingCountdown"; @@ -20,6 +21,7 @@ export default function NowMintingDetails({ nft }: NowMintingDetailsProps) { return `${Number.parseFloat(value.toFixed(5))} ETH`; }; const floorPrice = formatEth(nft.floor_price); + const fileMimeType = getFileMimeTypeFromMetadata(nft.metadata); return (
@@ -29,6 +31,7 @@ export default function NowMintingDetails({ nft }: NowMintingDetailsProps) { title={nft.name} artistHandle={nft.artist_seize_handle} artistName={nft.artist} + mediaMimeType={fileMimeType} />
+ {mediaMimeType && ( + + )} Card #{cardNumber} diff --git a/components/memelab/MemeLab.tsx b/components/memelab/MemeLab.tsx index 0914b0c11b..1390f1b0b6 100644 --- a/components/memelab/MemeLab.tsx +++ b/components/memelab/MemeLab.tsx @@ -2,6 +2,7 @@ import { AuthContext } from "@/components/auth/Auth"; import CollectionsDropdown from "@/components/collections-dropdown/CollectionsDropdown"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; import DotLoader from "@/components/dotLoader/DotLoader"; import { LFGButton } from "@/components/lfg-slideshow/LFGSlideshow"; import styles from "@/components/memelab/MemeLab.module.scss"; @@ -22,6 +23,7 @@ import { numberWithCommas, printMintDate, } from "@/helpers/Helpers"; +import { getNftMimeType } from "@/helpers/nft.helpers"; import { fetchAllPages } from "@/services/6529api"; import { MemeLabSort } from "@/types/enums"; import { @@ -536,6 +538,8 @@ export default function MemeLabComponent() { }, [sort, sortDir, nftsLoaded, volumeType]); function printNft(nft: LabNFT) { + const mediaMimeType = getNftMimeType(nft); + return ( - #{nft.id} - {nft.name} + + {mediaMimeType && ( + + )} + #{nft.id} - {nft.name} + diff --git a/components/memelab/MemeLabPage.tsx b/components/memelab/MemeLabPage.tsx index 3fddf41384..f538e66e24 100644 --- a/components/memelab/MemeLabPage.tsx +++ b/components/memelab/MemeLabPage.tsx @@ -7,6 +7,7 @@ import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; import CircleLoader, { CircleLoaderSize, } from "@/components/distribution-plan-tool/common/CircleLoader"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; import { ActivityTypeItems } from "@/components/latest-activity/ActivityFilters"; import LatestActivityRow from "@/components/latest-activity/LatestActivityRow"; import MemeLabLeaderboard from "@/components/leaderboard/MemeLabLeaderboard"; @@ -52,10 +53,13 @@ import { printMintDate, } from "@/helpers/Helpers"; import { + getAnimationMimeTypeFromMetadata, getAnimationDimensionsFromMetadata, getAnimationFileTypeFromMetadata, + getImageMimeTypeFromMetadata, getImageDimensionsFromMetadata, getImageFileTypeFromMetadata, + getMimeTypeFromFormat, } from "@/helpers/nft.helpers"; import { TypeFilter } from "@/hooks/useActivityData"; import useCapacitor from "@/hooks/useCapacitor"; @@ -77,6 +81,9 @@ const isAbortError = (error: unknown): boolean => { return error instanceof Error && error.name === "AbortError"; }; +const trimToEmpty = (value: unknown): string => + typeof value === "string" ? value.trim() : ""; + export default function MemeLabPageComponent({ nftId, }: { @@ -616,10 +623,10 @@ export default function MemeLabPageComponent({ Edition Size - + {numberWithCommas(nftMeta.edition_size)} - + {nftMeta.edition_size_rank}/{nftMeta.collection_size} @@ -635,16 +642,16 @@ export default function MemeLabPageComponent({ /> - + {numberWithCommas(nftMeta.burnt)} Edition Size ex. Burnt - + {numberWithCommas(nftMeta.edition_size_not_burnt)} - + {nftMeta.edition_size_not_burnt_rank}/ {nftMeta.collection_size} @@ -653,10 +660,10 @@ export default function MemeLabPageComponent({ )} 6529 Museum - + {numberWithCommas(nftMeta.museum_holdings)} - + {nftMeta.museum_holdings_rank}/{nftMeta.collection_size} @@ -665,42 +672,42 @@ export default function MemeLabPageComponent({ Edition Size ex. {nftMeta.burnt > 0 && " Burnt and"} 6529 Museum - + {numberWithCommas(nftMeta.edition_size_cleaned)} - + {nftMeta.edition_size_cleaned_rank}/ {nftMeta.collection_size} Collectors - + {numberWithCommas(nftMeta.hodlers)} - + {nftMeta.hodlers_rank}/{nftMeta.collection_size} % Unique - + {Math.round(nftMeta.percent_unique * 100 * 10) / 10}% - + {nftMeta.percent_unique_rank}/{nftMeta.collection_size} {nftMeta.burnt > 0 && ( % Unique ex. Burnt - + {Math.round( nftMeta.percent_unique_not_burnt * 100 * 10 ) / 10} % - + {nftMeta.percent_unique_not_burnt_rank}/ {nftMeta.collection_size} @@ -711,12 +718,12 @@ export default function MemeLabPageComponent({ % Unique ex.{nftMeta.burnt > 0 && " Burnt and"} 6529 Museum - + {Math.round(nftMeta.percent_unique_cleaned * 100 * 10) / 10} % - + {nftMeta.percent_unique_cleaned_rank}/ {nftMeta.collection_size} @@ -983,7 +990,7 @@ export default function MemeLabPageComponent({ const imageDimensions = getImageDimensionsFromMetadata(nft?.metadata); const animationDimensions = getAnimationDimensionsFromMetadata(nft?.metadata); const imageHref = getResolvedImageSrc(nft); - const metadataHref = nft?.uri.trim() ?? ""; + const metadataHref = trimToEmpty(nft?.uri); const metadata = nft?.metadata !== null && typeof nft?.metadata === "object" ? (nft.metadata as { @@ -993,22 +1000,21 @@ export default function MemeLabPageComponent({ }) : undefined; const artImageHref = - (typeof metadata?.image === "string" ? metadata.image.trim() : "") || - nft?.image.trim() || - ""; + trimToEmpty(metadata?.image) || trimToEmpty(nft?.image) || ""; const artAnimationHref = - (typeof metadata?.animation_url === "string" - ? metadata.animation_url.trim() - : "") || - (typeof metadata?.animation === "string" - ? metadata.animation.trim() - : "") || - nft?.animation.trim() || + trimToEmpty(metadata?.animation_url) || + trimToEmpty(metadata?.animation) || + trimToEmpty(nft?.animation) || ""; const hasImage = Boolean(imageHref); const isShowingAnimation = hasAnimation && (currentSlide === 0 || !hasImage); const fileType = isShowingAnimation ? animationFormat : imageFormat; const dimensions = isShowingAnimation ? animationDimensions : imageDimensions; + const fileMimeType = isShowingAnimation + ? (getAnimationMimeTypeFromMetadata(nft?.metadata) ?? + getMimeTypeFromFormat(animationFormat)) + : (getImageMimeTypeFromMetadata(nft?.metadata) ?? + getMimeTypeFromFormat(imageFormat)); const currentFormat = fileType ?? ""; const arweaveRows = [ metadataHref @@ -1173,36 +1179,52 @@ export default function MemeLabPageComponent({ Edition Size - {nft.supply} + {nft.supply} Collection - {nft.collection} + {nft.collection} + + + Mint Date + + {printMintDate(nft.mint_date)} + Artist Name - {nft.artist} + {nft.artist} Artist Profile - + - - Mint Date - {printMintDate(nft.mint_date)} - {fileType && ( File Type - {fileType} + + {fileMimeType ? ( + + ) : ( + fileType + )} + )} {dimensions && ( Dimensions - {dimensions} + {dimensions} )} @@ -1245,7 +1267,7 @@ export default function MemeLabPageComponent({ .map((a: any) => ( {a.trait_type} - {a.value} + {a.value} ))} diff --git a/components/nft-attributes/ArweaveLinksTable.tsx b/components/nft-attributes/ArweaveLinksTable.tsx index d4b3b82cca..dd3bd62b51 100644 --- a/components/nft-attributes/ArweaveLinksTable.tsx +++ b/components/nft-attributes/ArweaveLinksTable.tsx @@ -26,13 +26,11 @@ export function ArweaveLinksTable(props: { {props.rows.map((row) => (
-
- {row.label} -
+
{row.label}
-
+
View )} + {props.afterMetadata} {hasHodlRate && ( {props.label} - + {value > 0 ? `${numberWithCommas(value)} ${props.unit ?? ""}` : `N/A`} - + ); diff --git a/components/nft-image/NFTImage.tsx b/components/nft-image/NFTImage.tsx index e9a7856433..8f8a26bbdd 100644 --- a/components/nft-image/NFTImage.tsx +++ b/components/nft-image/NFTImage.tsx @@ -1,10 +1,10 @@ import type { BaseNFT, NFTLite } from "@/entities/INFT"; -import styles from "./NFTImage.module.scss"; -import NFTHTMLRenderer from "./renderers/NFTHTMLRenderer"; -import NFTImageRenderer from "./renderers/NFTImageRenderer"; -import NFTModelRenderer from "./renderers/NFTModelRenderer"; -import NFTVideoRenderer from "./renderers/NFTVideoRenderer"; -import { getMediaType } from "./utils/media-type"; +import styles from "@/components/nft-image/NFTImage.module.scss"; +import NFTHTMLRenderer from "@/components/nft-image/renderers/NFTHTMLRenderer"; +import NFTImageRenderer from "@/components/nft-image/renderers/NFTImageRenderer"; +import NFTModelRenderer from "@/components/nft-image/renderers/NFTModelRenderer"; +import NFTVideoRenderer from "@/components/nft-image/renderers/NFTVideoRenderer"; +import { getMediaType } from "@/components/nft-image/utils/media-type"; interface Props { nft: BaseNFT | NFTLite; diff --git a/components/nft-image/NFTImageBalance.tsx b/components/nft-image/NFTImageBalance.tsx index a0fccef6c1..676befc571 100644 --- a/components/nft-image/NFTImageBalance.tsx +++ b/components/nft-image/NFTImageBalance.tsx @@ -1,8 +1,8 @@ "use client"; import { useAuth } from "@/components/auth/Auth"; +import styles from "@/components/nft-image/NFTImage.module.scss"; import { useNftBalance } from "@/hooks/useNftBalance"; -import styles from "./NFTImage.module.scss"; interface Props { readonly contract: string; @@ -34,9 +34,10 @@ export default function NFTImageBalance({ contract, tokenId, height }: Props) { const printBalanceSpan = (b: string) => { return ( + } `} + > {b} ); diff --git a/components/nft-image/NFTModel.tsx b/components/nft-image/NFTModel.tsx index 839d955ed9..3c23660bee 100644 --- a/components/nft-image/NFTModel.tsx +++ b/components/nft-image/NFTModel.tsx @@ -2,8 +2,8 @@ import "@google/model-viewer"; import { useEffect, useMemo, useRef } from "react"; import type { SyntheticEvent } from "react"; import type { BaseNFT } from "@/entities/INFT"; -import { getResolvedAnimationSrc } from "./utils/animation-source"; -import { withArweaveFallback } from "./utils/gateway-fallback"; +import { getResolvedAnimationSrc } from "@/components/nft-image/utils/animation-source"; +import { withArweaveFallback } from "@/components/nft-image/utils/gateway-fallback"; type ModelViewerElement = HTMLElement & { src: string; diff --git a/components/nft-image/RememeImage.tsx b/components/nft-image/RememeImage.tsx index c5e285cc35..79f91a38ee 100644 --- a/components/nft-image/RememeImage.tsx +++ b/components/nft-image/RememeImage.tsx @@ -1,4 +1,4 @@ -import styles from "./NFTImage.module.scss"; +import styles from "@/components/nft-image/NFTImage.module.scss"; import { Col } from "react-bootstrap"; import type { Rememe } from "@/entities/INFT"; import Image from "next/image"; diff --git a/components/nft-image/renderers/NFTHTMLRenderer.tsx b/components/nft-image/renderers/NFTHTMLRenderer.tsx index 7bb3964a4d..b6508040c8 100644 --- a/components/nft-image/renderers/NFTHTMLRenderer.tsx +++ b/components/nft-image/renderers/NFTHTMLRenderer.tsx @@ -1,12 +1,15 @@ "use client"; +import NFTImageBalance from "@/components/nft-image/NFTImageBalance"; +import styles from "@/components/nft-image/NFTImage.module.scss"; +import type { BaseRendererProps } from "@/components/nft-image/types/renderer-props"; +import { getResolvedAnimationSrc } from "@/components/nft-image/utils/animation-source"; import { useEffect, useMemo, useState } from "react"; import { Col } from "react-bootstrap"; -import styles from "../NFTImage.module.scss"; -import NFTImageBalance from "../NFTImageBalance"; -import type { BaseRendererProps } from "../types/renderer-props"; -import { getResolvedAnimationSrc } from "../utils/animation-source"; -import { getArweaveGatewayFallbackUrls } from "../utils/gateway-fallback"; +import { + getArweaveGatewayFallbackUrls, + shouldUseIframeFallbackTimeout, +} from "@/components/nft-image/utils/gateway-fallback"; const IFRAME_FALLBACK_TIMEOUT_MS = 8000; @@ -40,7 +43,12 @@ export default function NFTHTMLRenderer(props: Readonly) { }, [activeUrl]); useEffect(() => { - if (!activeUrl || didLoadCurrentUrl || activeIndex + 1 >= urls.length) { + if ( + !activeUrl || + didLoadCurrentUrl || + activeIndex + 1 >= urls.length || + !shouldUseIframeFallbackTimeout(activeUrl) + ) { return; } diff --git a/components/nft-image/renderers/NFTImageRenderer.tsx b/components/nft-image/renderers/NFTImageRenderer.tsx index db831768c5..41a265cc4e 100644 --- a/components/nft-image/renderers/NFTImageRenderer.tsx +++ b/components/nft-image/renderers/NFTImageRenderer.tsx @@ -1,11 +1,11 @@ "use client"; +import NFTImageBalance from "@/components/nft-image/NFTImageBalance"; +import styles from "@/components/nft-image/NFTImage.module.scss"; +import type { BaseRendererProps } from "@/components/nft-image/types/renderer-props"; +import { withArweaveFallback } from "@/components/nft-image/utils/gateway-fallback"; import Image from "next/image"; import { Col } from "react-bootstrap"; -import styles from "../NFTImage.module.scss"; -import NFTImageBalance from "../NFTImageBalance"; -import type { BaseRendererProps } from "../types/renderer-props"; -import { withArweaveFallback } from "../utils/gateway-fallback"; function getSrc( nft: BaseRendererProps["nft"], diff --git a/components/nft-image/renderers/NFTModelRenderer.tsx b/components/nft-image/renderers/NFTModelRenderer.tsx index d1a76de1e9..5c34d4c619 100644 --- a/components/nft-image/renderers/NFTModelRenderer.tsx +++ b/components/nft-image/renderers/NFTModelRenderer.tsx @@ -1,12 +1,11 @@ "use client"; +import NFTImageBalance from "@/components/nft-image/NFTImageBalance"; +import styles from "@/components/nft-image/NFTImage.module.scss"; +import NFTModel from "@/components/nft-image/NFTModel"; +import type { BaseRendererProps } from "@/components/nft-image/types/renderer-props"; import { Col } from "react-bootstrap"; -import styles from "../NFTImage.module.scss"; -import NFTImageBalance from "../NFTImageBalance"; -import NFTModel from "../NFTModel"; -import type { BaseRendererProps } from "../types/renderer-props"; - export default function NFTModelRenderer(props: Readonly) { // Only render if NFT has metadata (i.e., it's a BaseNFT, not NFTLite) if (!("metadata" in props.nft)) { diff --git a/components/nft-image/renderers/NFTVideoRenderer.tsx b/components/nft-image/renderers/NFTVideoRenderer.tsx index cedf6109ac..0d2bc94a67 100644 --- a/components/nft-image/renderers/NFTVideoRenderer.tsx +++ b/components/nft-image/renderers/NFTVideoRenderer.tsx @@ -1,11 +1,11 @@ "use client"; +import NFTImageBalance from "@/components/nft-image/NFTImageBalance"; +import styles from "@/components/nft-image/NFTImage.module.scss"; +import type { BaseRendererProps } from "@/components/nft-image/types/renderer-props"; +import { getResolvedAnimationSrc } from "@/components/nft-image/utils/animation-source"; +import { withArweaveFallback } from "@/components/nft-image/utils/gateway-fallback"; import { Col } from "react-bootstrap"; -import styles from "../NFTImage.module.scss"; -import NFTImageBalance from "../NFTImageBalance"; -import type { BaseRendererProps } from "../types/renderer-props"; -import { getResolvedAnimationSrc } from "../utils/animation-source"; -import { withArweaveFallback } from "../utils/gateway-fallback"; const globalScope = globalThis as typeof globalThis & { window?: Window | undefined; diff --git a/components/nft-image/utils/gateway-fallback.ts b/components/nft-image/utils/gateway-fallback.ts index 26d406f0e0..c8b4a84786 100644 --- a/components/nft-image/utils/gateway-fallback.ts +++ b/components/nft-image/utils/gateway-fallback.ts @@ -100,22 +100,51 @@ type MediaErrorEvent = React.SyntheticEvent< const DS_ORIGINAL = "arweaveOriginalSrc"; const DS_LAST_HOST = "arweaveLastGatewayHost"; -export function getArweaveGatewayFallbackUrls(url: string): string[] { +function classifyGatewayAssetUrl(url: string): + | { kind: "empty" } + | { kind: "ipfs"; sourceUrl: string } + | { kind: "arweave"; sourceUrl: string } + | { kind: "other"; sourceUrl: string } { const trimmed = url.trim(); if (!trimmed) { - return []; + return { kind: "empty" }; } + if (isIpfsProtocolUrl(trimmed)) { - return getIpfsFallbackUrls(trimmed); + return { kind: "ipfs", sourceUrl: trimmed }; } + const ipfsProtocolUrl = getIpfsProtocolUrlFromGatewayUrl(trimmed); if (ipfsProtocolUrl) { - return getIpfsFallbackUrls(ipfsProtocolUrl); + return { kind: "ipfs", sourceUrl: ipfsProtocolUrl }; + } + + if (isArweaveUrl(trimmed)) { + return { kind: "arweave", sourceUrl: trimmed }; + } + + return { kind: "other", sourceUrl: trimmed }; +} + +export function getArweaveGatewayFallbackUrls(url: string): string[] { + const assetUrl = classifyGatewayAssetUrl(url); + if (assetUrl.kind === "empty") { + return []; } - if (!isArweaveUrl(trimmed)) { - return [trimmed]; + + if (assetUrl.kind === "ipfs") { + return getIpfsFallbackUrls(assetUrl.sourceUrl); } - return getTryList(trimmed, trimmed); + + if (assetUrl.kind === "other") { + return [assetUrl.sourceUrl]; + } + + return getTryList(assetUrl.sourceUrl, assetUrl.sourceUrl); +} + +export function shouldUseIframeFallbackTimeout(url: string): boolean { + return classifyGatewayAssetUrl(url).kind === "arweave"; } function getTryList(currentSrc: string, originalSrc: string): string[] { diff --git a/components/nft-image/utils/media-type.ts b/components/nft-image/utils/media-type.ts index cc7dd8a6bc..46a24a968e 100644 --- a/components/nft-image/utils/media-type.ts +++ b/components/nft-image/utils/media-type.ts @@ -1,5 +1,5 @@ import type { BaseNFT, NFTLite } from "@/entities/INFT"; -import { getResolvedAnimationSrc } from "./animation-source"; +import { getResolvedAnimationSrc } from "@/components/nft-image/utils/animation-source"; type MediaType = "html" | "glb" | "video" | "image"; diff --git a/components/the-memes/MemePageArt.tsx b/components/the-memes/MemePageArt.tsx index 95116f08e4..34ba27739e 100644 --- a/components/the-memes/MemePageArt.tsx +++ b/components/the-memes/MemePageArt.tsx @@ -1,5 +1,6 @@ "use client"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; import { ArweaveLinksTable } from "@/components/nft-attributes/ArweaveLinksTable"; import NFTAttributes from "@/components/nft-attributes/NFTAttributes"; import NFTImage from "@/components/nft-image/NFTImage"; @@ -16,8 +17,10 @@ import { import { getAnimationDimensionsFromMetadata, getAnimationFileTypeFromMetadata, + getAnimationMimeTypeFromMetadata, getImageDimensionsFromMetadata, getImageFileTypeFromMetadata, + getImageMimeTypeFromMetadata, } from "@/helpers/nft.helpers"; import { faExpandAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -33,6 +36,14 @@ const PROPERTY_EXCLUDED_TRAITS = new Set([ "Type - Card", ]); +function formatTdhRate(value: number | undefined): string { + if (!value || value <= 0) { + return "N/A"; + } + + return numberWithCommas(Math.round(value * 100) / 100); +} + function shouldShowPropertyAttribute(attribute: IAttribute): boolean { const displayType = attribute.display_type?.trim().toLowerCase(); @@ -93,6 +104,9 @@ export function MemePageArt(props: { fullscreenElementId = "the-art-fullscreen-img"; } const fileType = isShowingAnimation ? animationFormat : imageFormat; + const fileTypeMimeType = isShowingAnimation + ? getAnimationMimeTypeFromMetadata(props.nft?.metadata) + : getImageMimeTypeFromMetadata(props.nft?.metadata); const dimensions = isShowingAnimation ? animationDimensions : imageDimensions; const arweaveRows = [ metadataHref @@ -137,6 +151,128 @@ export function MemePageArt(props: { return `https://github.com/6529-Collections/thememecards/tree/main/card1-3`; })(); + const detailRows = [ + { + key: "edition-size", + label: "Edition Size", + value: {props.nft?.supply}, + }, + { + key: "collection", + label: "Collection", + value: {props.nft?.collection}, + }, + { + key: "season", + label: "Season", + value: {props.nftMeta?.season}, + }, + { + key: "meme", + label: "Meme", + value: {props.nftMeta?.meme_name}, + }, + { + key: "mint-date", + label: "Mint Date", + value: ( + + {printMintDate(props.nft?.mint_date)} + + ), + }, + { + key: "artist-name", + label: "Artist Name", + value: {props.nft?.artist}, + }, + { + key: "artist-profile", + label: "Artist Profile", + value: ( + + {props.nft ? : null} + + ), + }, + ...(fileType + ? [ + { + key: "file-type", + label: "File Type", + value: ( + + + + ), + }, + ] + : []), + ...(distributionPlanLink + ? [ + { + key: "minting-approach", + label: "Minting Approach", + value: ( + + + Distribution Plan + + + ), + }, + ] + : []), + { + key: "mint-price", + label: "Mint Price", + value: ( + + {props.nft && props.nft.mint_price > 0 + ? `${numberWithCommas( + Math.round(props.nft.mint_price * 100000) / 100000 + )} ETH` + : `N/A`} + + ), + }, + { + key: "tdh-rate", + label: "TDH Rate", + value: ( + + {formatTdhRate(props.nft?.hodl_rate)} + + ), + }, + { + key: "dimensions", + label: "Dimensions", + value: {dimensions || "N/A"}, + }, + ]; + const detailSplitIndex = Math.ceil(detailRows.length / 2); + const detailColumns = [ + detailRows.slice(0, detailSplitIndex), + detailRows.slice(detailSplitIndex), + ]; + useEffect(() => { setIsFullScreenSupported(fullScreenSupported()); }, []); @@ -265,12 +401,7 @@ export function MemePageArt(props: { - + @@ -278,96 +409,37 @@ export function MemePageArt(props: { - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {fileType && ( - - - - - )} - {dimensions && ( - - - + {detailRows.map((row) => ( + + + - )} + ))}
Edition Size{props.nft.supply}
Collection{props.nft.collection}
Season{props.nftMeta.season}
Meme{props.nftMeta.meme_name}
Artist Name{props.nft.artist}
Artist Profile - -
Mint Date{printMintDate(props.nft.mint_date)}
File Type{fileType}
Dimensions{dimensions}
{row.label}{row.value}
-
-
- - - - - -

Minting Approach

- -
- {distributionPlanLink && ( - - - - Distribution Plan - + {detailColumns.map((columnRows) => ( + row.key).join("-")} + xs={{ span: 12 }} + lg={{ span: 6 }} + className="d-none d-lg-block" + > + + + {columnRows.map((row) => ( + + + + + ))} + +
{row.label}{row.value}
-
- )} - - - Mint price:{" "} - {props.nft.mint_price > 0 - ? `${numberWithCommas( - Math.round(props.nft.mint_price * 100000) / 100000 - )} ETH` - : `N/A`} - + ))}
diff --git a/components/the-memes/MemePageLive.tsx b/components/the-memes/MemePageLive.tsx index 0df059ec23..50f6f3a25e 100644 --- a/components/the-memes/MemePageLive.tsx +++ b/components/the-memes/MemePageLive.tsx @@ -1,6 +1,7 @@ "use client"; import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; import { NftPageStats } from "@/components/nft-attributes/NftStats"; import RememeImage from "@/components/nft-image/RememeImage"; import NFTMarketplaceLinks from "@/components/nft-marketplace-links/NFTMarketplaceLinks"; @@ -17,6 +18,7 @@ import { numberWithCommas, printMintDate, } from "@/helpers/Helpers"; +import { getFileMimeTypeFromMetadata } from "@/helpers/nft.helpers"; import useCapacitor from "@/hooks/useCapacitor"; import { fetchUrl } from "@/services/6529api"; import { faFire, faRefresh } from "@fortawesome/free-solid-svg-icons"; @@ -49,6 +51,7 @@ export function MemePageLiveRightMenu(props: { return `https://github.com/6529-Collections/thememecards/tree/main/card${id}`; return `https://github.com/6529-Collections/thememecards/tree/main/card1-3`; })(); + const fileMimeType = getFileMimeTypeFromMetadata(props.nft?.metadata); if (props.show && props.nft && props.nftMeta) { return ( @@ -76,10 +79,10 @@ export function MemePageLiveRightMenu(props: { Edition Size - + {numberWithCommas(props.nftMeta.edition_size)} - + {props.nftMeta.edition_size_rank}/ {props.nftMeta.collection_size} @@ -96,18 +99,18 @@ export function MemePageLiveRightMenu(props: { /> - + {numberWithCommas(props.nftMeta.burnt)} Edition Size ex. Burnt - + {numberWithCommas( props.nftMeta.edition_size_not_burnt )} - + {props.nftMeta.edition_size_not_burnt_rank}/ {props.nftMeta.collection_size} @@ -116,10 +119,10 @@ export function MemePageLiveRightMenu(props: { )} 6529 Museum - + {numberWithCommas(props.nftMeta.museum_holdings)} - + {props.nftMeta.museum_holdings_rank}/ {props.nftMeta.collection_size} @@ -129,31 +132,31 @@ export function MemePageLiveRightMenu(props: { Edition Size ex.{props.nftMeta.burnt > 0 && " Burnt and"}{" "} 6529 Museum - + {numberWithCommas(props.nftMeta.edition_size_cleaned)} - + {props.nftMeta.edition_size_cleaned_rank}/ {props.nftMeta.collection_size} Collectors - + {numberWithCommas(props.nftMeta.hodlers)} - + {props.nftMeta.hodlers_rank}/ {props.nftMeta.collection_size} % Unique - + {Math.round(props.nftMeta.percent_unique * 100 * 10) / 10} % - + {props.nftMeta.percent_unique_rank}/ {props.nftMeta.collection_size} @@ -161,13 +164,13 @@ export function MemePageLiveRightMenu(props: { {props.nftMeta.burnt > 0 && ( % Unique ex. Burnt - + {Math.round( props.nftMeta.percent_unique_not_burnt * 100 * 10 ) / 10} % - + {props.nftMeta.percent_unique_not_burnt_rank}/ {props.nftMeta.collection_size} @@ -178,13 +181,13 @@ export function MemePageLiveRightMenu(props: { % Unique ex.{props.nftMeta.burnt > 0 && " Burnt and"} 6529 Museum - + {Math.round( props.nftMeta.percent_unique_cleaned * 100 * 10 ) / 10} % - + {props.nftMeta.percent_unique_cleaned_rank}/ {props.nftMeta.collection_size} @@ -204,19 +207,40 @@ export function MemePageLiveRightMenu(props: { Artist Name - {props.nft.artist} + {props.nft.artist} Artist Profile - + Mint Date - {printMintDate(props.nft.mint_date)} + + {printMintDate(props.nft.mint_date)} + - + + File Type + + + + + ) : null + } + /> diff --git a/components/the-memes/TheMemes.tsx b/components/the-memes/TheMemes.tsx index 0e7d384695..10b32526b1 100644 --- a/components/the-memes/TheMemes.tsx +++ b/components/the-memes/TheMemes.tsx @@ -2,6 +2,7 @@ import { AuthContext } from "@/components/auth/Auth"; import CollectionsDropdown from "@/components/collections-dropdown/CollectionsDropdown"; +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; import DotLoader from "@/components/dotLoader/DotLoader"; import { LFGButton } from "@/components/lfg-slideshow/LFGSlideshow"; import NFTImage from "@/components/nft-image/NFTImage"; @@ -17,6 +18,7 @@ import { VolumeType } from "@/entities/INFT"; import type { MemeSeason } from "@/entities/ISeason"; import { SortDirection } from "@/entities/ISort"; import { numberWithCommas, printMintDate } from "@/helpers/Helpers"; +import { getNftMimeType } from "@/helpers/nft.helpers"; import { fetchUrl } from "@/services/6529api"; import type { MemeLabSort } from "@/types/enums"; import { MEMES_EXTENDED_SORT, MemesSort } from "@/types/enums"; @@ -321,6 +323,8 @@ export default function TheMemesComponent() { } function printNft(nft: NFTWithMemesExtendedData) { + const mediaMimeType = getNftMimeType(nft); + return ( - #{nft.id} - {nft.name} + + {mediaMimeType && ( + + )} + #{nft.id} - {nft.name} + diff --git a/helpers/nft.helpers.ts b/helpers/nft.helpers.ts index 6a4d7c52ad..c8101a5aba 100644 --- a/helpers/nft.helpers.ts +++ b/helpers/nft.helpers.ts @@ -4,6 +4,9 @@ import { MEMES_CONTRACT, NEXTGEN_CONTRACT, } from "@/constants/constants"; +import type { BaseNFT, NFTLite } from "@/entities/INFT"; +import { getResolvedAnimationSrc } from "@/components/nft-image/utils/animation-source"; +import { getResolvedImageSrc } from "@/components/nft-image/utils/image-source"; import { numberWithCommas } from "./Helpers"; interface NFTMediaDetails { @@ -32,6 +35,33 @@ const getMediaFormat = ( return format.length > 0 ? format : null; }; +const getFormatMimeKey = (format: string | null | undefined): string | null => { + if (typeof format !== "string") { + return null; + } + + const trimmed = format.trim(); + return trimmed.length > 0 ? trimmed.toUpperCase() : null; +}; + +const FORMAT_TO_MIME_TYPE: Record = { + PNG: "image/png", + JPEG: "image/jpeg", + JPG: "image/jpeg", + GIF: "image/gif", + WEBP: "image/webp", + SVG: "image/svg+xml", + MP4: "video/mp4", + WEBM: "video/webm", + MOV: "video/quicktime", + QT: "video/quicktime", + M4V: "video/x-m4v", + GLB: "model/gltf-binary", + GLTF: "model/gltf+json", + HTML: "text/html", + HTM: "text/html", +}; + const isValidDimension = ( value: number | null | undefined ): value is number => { @@ -82,6 +112,95 @@ export function getFileTypeFromMetadata(metadata: NFTMediaMetadata) { ); } +export function getMimeTypeFromFormat( + format: string | null | undefined +): string | null { + const normalizedFormat = getFormatMimeKey(format); + if (!normalizedFormat) { + return null; + } + + return FORMAT_TO_MIME_TYPE[normalizedFormat] ?? null; +} + +function getUrlExtension(url: string | null | undefined): string | null { + if (!url) { + return null; + } + + const clean = url.split("?")[0]?.split("#")[0] ?? ""; + const parts = clean.split("."); + if (parts.length < 2) { + return null; + } + + const ext = parts.at(-1)?.trim().toUpperCase(); + return ext || null; +} + +export function getAnimationMimeTypeFromMetadata( + metadata: NFTMediaMetadata +): string | null { + return getMimeTypeFromFormat(getAnimationFileTypeFromMetadata(metadata)); +} + +export function getImageMimeTypeFromMetadata( + metadata: NFTMediaMetadata +): string | null { + return getMimeTypeFromFormat(getImageFileTypeFromMetadata(metadata)); +} + +export function getFileMimeTypeFromMetadata(metadata: NFTMediaMetadata) { + return ( + getAnimationMimeTypeFromMetadata(metadata) ?? + getImageMimeTypeFromMetadata(metadata) + ); +} + +export function getNftMimeType( + nft: BaseNFT | NFTLite | undefined +): string | null { + if (!nft) { + return null; + } + + const metadata = + "metadata" in nft && + nft.metadata !== null && + typeof nft.metadata === "object" + ? (nft.metadata as NFTMediaMetadata) + : undefined; + + const metadataMimeType = getFileMimeTypeFromMetadata(metadata); + if (metadataMimeType) { + return metadataMimeType; + } + + const animationSrc = getResolvedAnimationSrc(nft); + const animationMimeType = getMimeTypeFromFormat( + getUrlExtension(animationSrc) + ); + if (animationMimeType) { + return animationMimeType; + } + + if (animationSrc) { + return "video/mp4"; + } + + const imageSrc = getResolvedImageSrc(nft); + const imageMimeType = getMimeTypeFromFormat(getUrlExtension(imageSrc)); + if (imageMimeType) { + return imageMimeType; + } + + if (imageSrc) { + return "image/jpeg"; + } + + return null; +} + export function getDimensionsFromMetadata(metadata: NFTMediaMetadata) { return ( getAnimationDimensionsFromMetadata(metadata) ?? diff --git a/hooks/useNowMintingStatus.ts b/hooks/useNowMintingStatus.ts index d3c0c0c5fc..d744428026 100644 --- a/hooks/useNowMintingStatus.ts +++ b/hooks/useNowMintingStatus.ts @@ -7,6 +7,7 @@ type NowMintingStatus = { readonly nft: NFTWithMemesExtendedData | undefined; readonly isFetching: boolean; readonly status: ManifoldClaimStatus | null; + readonly isDropComplete: boolean; readonly isStatusLoading: boolean; readonly error: unknown; }; @@ -19,6 +20,7 @@ export const useNowMintingStatus = (): NowMintingStatus => { nft, isFetching, status: claim?.status ?? null, + isDropComplete: claim?.isDropComplete ?? false, isStatusLoading: !!nft && !claim, error, };