Repository: m1guelpf/mirror-next Branch: main Commit: 5804ee09d479 Files: 47 Total size: 63.2 KB Directory structure: gitextract_m1cqlie7/ ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── patches/ │ └── re2@1.20.11.patch ├── postcss.config.js ├── scripts/ │ └── resolve-ens.js ├── src/ │ ├── app/ │ │ ├── [digest]/ │ │ │ └── page.tsx │ │ ├── api/ │ │ │ ├── link-preview/ │ │ │ │ └── route.ts │ │ │ └── nft-data/ │ │ │ └── route.ts │ │ ├── error.tsx │ │ ├── feed.xml/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── post/ │ │ │ └── [slug]/ │ │ │ └── route.ts │ │ └── posts.json/ │ │ └── route.ts │ ├── components/ │ │ ├── EntryLink.tsx │ │ ├── LinkButton.tsx │ │ ├── NFT.tsx │ │ └── OpenGraph.tsx │ ├── context/ │ │ ├── image_sizes.tsx │ │ └── theme.tsx │ ├── data/ │ │ ├── ERC721.ts │ │ ├── ens.ts │ │ ├── entries.ts │ │ └── publication.ts │ ├── hooks/ │ │ └── getConfig.ts │ ├── lib/ │ │ ├── arweave.ts │ │ └── graphql.ts │ ├── queries/ │ │ ├── arweave/ │ │ │ ├── fetch-single-transaction.ts │ │ │ └── fetch-transactions.ts │ │ └── mirror/ │ │ └── fetch-publication.ts │ ├── styles/ │ │ └── style.css │ ├── types/ │ │ └── link-preview.ts │ └── utils/ │ ├── address.ts │ ├── embeds.ts │ ├── excerpt.ts │ ├── highlightMarkdown.ts │ ├── images.ts │ ├── markdown.tsx │ └── url.ts ├── tailwind.config.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel # custom src/data/ens.js ================================================ FILE: LICENSE ================================================ Copyright (c) Miguel Piedrafita Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # A Next.js-powered frontend for your Mirror blog > This project mimics the [Mirror](https://mirror.xyz) publication design and pulls data from their APIs, allowing you to self-host your Mirror blog on a custom domain. You can view a demo of this project by visiting [m1guelpf.blog](https://m1guelpf.blog), which hosts the [my Mirror publication](https://miguel.mirror.xyz). ## Features - [x] Article list - [x] Article page - [x] Code highlighting (using VSCode's rendering engine, allows for custom themes. Currently using `github-light` and `github-dark`, depending on the publication's theme) - [x] Dark mode (when enabled on Mirror) - [x] Patience page (when no articles exist) - [x] Static generation (all pages should load instantly once deployed) - [x] Static re-generation (new articles should appear without re-deploying) - [x] Embeds - [x] Tweet embeds - [x] YouTube embeds - [x] Additional embeds (CodePen, JSBin, Gists, etc., not sure if supported by Mirror already) - [x] NFT embeds - [x] Bookmark cards (Open Graph) - [x] Email list support (when enabled on Mirror) - [x] Pull content from Arweave - [x] Write Mirror entry about this project ## Development - Clone this repo in a local directory - Install dependencies (`pnpm install`) - Copy the `.env.example` file to `.env.local`, and fill in your mirror subdomain and an RPC URL - Start the server! (`pnpm dev`) ## Deploying to Vercel You can deploy this project to Vercel (and load your own publication!) by clicking the button below: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fm1guelpf%2Fmirror-next&env=NEXT_PUBLIC_AUTHOR_ENS,NEXT_PUBLIC_RPC_URL&envDescription=The%20ENS%20for%20the%20publication%20you%20want%20to%20load%2C%20and%20an%20RPC%20URL.&project-name=mirror-next&repo-name=mirror-next) Once it's ready, you should be able to attach your custom domain from the Vercel settings page. ## FAQ **Is this decentralized?** Kind of. While I'm pulling the entry listing and contents from the Arweave chain directly, the publication details come from Mirror's APIs. **Why did you make this?** I like playing with stuff :). I really like Mirror's design, so I decided to create a [Ghost](https://ghost.org) theme "inspired" by their design. Once that was finished, I decided to turn that theme into something slightly more useful. **Who are you?** :wave: Hi! I'm [Miguel Piedrafita](https://twitter.com/m1guelpf), an 22-year-old maker. You can follow my journey and all the little things I make on the way [on Twitter](https://twitter.com/m1guelpf). **I have another question** Read [Building apps with Mirror](https://m1guelpf.blog/post/building-apps-with-mirror), an entry I wrote in my own Mirror publication explaining how this project works, and how you can build your own Mirror apps. If you still have questions after that, [drop me a line on Twitter](https://twitter.com/m1guelpf). ## License This project is open-sourced software licensed under the MIT license. See the [License file](LICENSE.md) for more information. ================================================ FILE: next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: next.config.js ================================================ import resolveEns from './scripts/resolve-ens.js' export default () => { resolveEns() return { images: { remotePatterns: [{ hostname: 'images.mirror-media.xyz' }], }, } } ================================================ FILE: package.json ================================================ { "name": "mirror-next", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "@apollo/client": "^3.10.4", "@heroicons/react": "^2.1.3", "@tailwindcss/typography": "^0.5.13", "arweave": "^1.15.1", "autoprefixer": "^10.4.19", "fs": "^0.0.1-security", "graphql": "^16.8.1", "image-size": "^1.1.1", "metascraper": "^5.45.8", "metascraper-author": "^5.45.7", "metascraper-clearbit": "^5.45.7", "metascraper-date": "^5.45.7", "metascraper-description": "^5.45.7", "metascraper-image": "^5.45.7", "metascraper-lang": "^5.45.7", "metascraper-logo": "^5.45.7", "metascraper-logo-favicon": "^5.45.7", "metascraper-publisher": "^5.45.7", "metascraper-title": "^5.45.7", "metascraper-url": "^5.45.7", "net": "^1.0.2", "next": "^14.2.3", "postcss": "^8.4.38", "react": "18.3.1", "react-dom": "18.3.1", "react-embed": "^3.7.0", "react-markdown": "^9.0.1", "react-medium-image-zoom": "^4.3.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", "remark-stringify": "^11.0.0", "rss": "^1.2.2", "shiki": "^1.6.0", "slug": "^9.0.0", "strip-markdown": "^6.0.0", "swr": "^2.2.5", "tailwindcss": "^3.4.3", "timeago.js": "^4.0.2", "tls": "^0.0.1", "unified": "^11.0.4", "unist-util-visit": "^5.0.0", "viem": "^2.11.1" }, "devDependencies": { "eslint": "^9.3.0", "prettier": "^2.2.1", "prettier-plugin-sort-imports-desc": "^1.0.0", "typescript": "5.4.5" }, "prettier": { "semi": false, "tabWidth": 4, "useTabs": true, "printWidth": 120, "singleQuote": true, "arrowParens": "avoid", "trailingComma": "es5", "bracketSpacing": true, "plugins": [ "prettier-plugin-sort-imports-desc" ] }, "pnpm": { "patchedDependencies": { "re2@1.20.11": "patches/re2@1.20.11.patch" } } } ================================================ FILE: patches/re2@1.20.11.patch ================================================ diff --git a/package.json b/package.json index c3bc354aa66946406f62bd7805fc88182039b081..b53fb74d1410881b38b0fcbac2f7ad49a12fe778 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test": "node tests/tests.js", "ts-test": "tsc", "save-to-github": "save-to-github-cache --artifact build/Release/re2.node", - "install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --skip-path-var RE2_DOWNLOAD_SKIP_PATH --skip-ver-var RE2_DOWNLOAD_SKIP_VER || node-gyp rebuild", + "original-install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --skip-path-var RE2_DOWNLOAD_SKIP_PATH --skip-ver-var RE2_DOWNLOAD_SKIP_VER || node-gyp rebuild", + "install": "echo 'Skipping re2 installation.'", "verify-build": "node scripts/verify-build.js", "rebuild": "node-gyp rebuild" }, diff --git a/re2.js b/re2.js index 3f32be925d96464242e503c03855c37ac5b354af..fdecf3f1a46d9f36a81b211fd5dbba9426b4bceb 100644 --- a/re2.js +++ b/re2.js @@ -1,38 +1,3 @@ 'use strict'; -const RE2 = require('./build/Release/re2.node'); - -if (typeof Symbol != 'undefined') { - Symbol.match && - (RE2.prototype[Symbol.match] = function (str) { - return this.match(str); - }); - Symbol.search && - (RE2.prototype[Symbol.search] = function (str) { - return this.search(str); - }); - Symbol.replace && - (RE2.prototype[Symbol.replace] = function (str, repl) { - return this.replace(str, repl); - }); - Symbol.split && - (RE2.prototype[Symbol.split] = function (str, limit) { - return this.split(str, limit); - }); - Symbol.matchAll && - (RE2.prototype[Symbol.matchAll] = function* (str) { - if (!this.global) { - throw TypeError('String.prototype.matchAll called with a non-global RE2 argument'); - } - const re = new RE2(this); - re.lastIndex = this.lastIndex; - for (;;) { - const result = re.exec(str); - if (!result) break; - if (result[0] === '') ++re.lastIndex; - yield result; - } - }); -} - -module.exports = RE2; +module.exports = RegExp; ================================================ FILE: postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: scripts/resolve-ens.js ================================================ import fs from 'fs' import { normalize } from 'viem/ens' import { mainnet } from 'viem/chains' import { createPublicClient, http } from 'viem' export default async () => { if (!process.env.NEXT_PUBLIC_AUTHOR_ENS) { console.error('NEXT_PUBLIC_AUTHOR_ENS is not set') } if (fs.existsSync('./src/data/ens.ts')) { // If the author ENS wasn't set on the first run, we might have an invalid publication address. // This makes sure invalid addresses are re-calculated on every run instead of just the first one. if (!String(fs.readFileSync('./src/data/ens.ts')).includes("'null'")) return } const client = createPublicClient({ chain: mainnet, transport: http(process.env.NEXT_PUBLIC_RPC_URL) }) const publicationAddress = await client.getEnsAddress({ name: normalize(process.env.NEXT_PUBLIC_AUTHOR_ENS) }) fs.writeFileSync('./src/data/ens.ts', `export const contributorAddresses = ['${publicationAddress}']\n`) } ================================================ FILE: src/app/[digest]/page.tsx ================================================ import { unified } from 'unified' import rehypeRaw from 'rehype-raw' import strip from 'strip-markdown' import remarkParse from 'remark-parse' import { getEntry } from '@/data/entries' import { notFound } from 'next/navigation' import { uriTransformer } from '@/utils/url' import { format as timeago } from 'timeago.js' import remarkStringify from 'remark-stringify' import { formatAddress } from '@/utils/address' import getPublication from '@/data/publication' import { SetImageSizes } from '@/context/image_sizes' import highlightCode from '@/utils/highlightMarkdown' import type { Metadata, ResolvingMetadata } from 'next' import ReactMarkdown, { Components } from 'react-markdown' import { Image, LinkOrEmbed, BlockQuote, Block } from '@/utils/markdown' export const revalidate = 1 * 60 * 60 // refresh article contents every hour const components: Components = { img: Image, a: LinkOrEmbed, blockquote: BlockQuote, p: Block, } type Props = { params: { digest: string } } export async function generateMetadata({ params: { digest } }: Props, parent: ResolvingMetadata): Promise { const [publication, entry] = await Promise.all([getPublication(), getEntry(digest)]) if (!entry) return notFound() // If there's an image we want to use the second paragraph as the description instead of the first one. // We'll also strip the markdown from the description (to avoid things like links showing up) and trim the newline at the end const description = String( await unified() .use(remarkParse) .use(strip) .use(remarkStringify) .process(entry.body.split('\n\n')[entry.cover_image ? 1 : 0]) ).slice(0, -1) return { description, title: `${entry.title} — Mirror`, openGraph: { description, title: `${entry.title} — Mirror`, images: entry.cover_image ? [{ url: entry.cover_image }] : [], }, twitter: { description, card: 'summary_large_image', title: `${entry.title} — Mirror`, images: entry.cover_image ? [{ url: entry.cover_image }] : [], }, } } const Article = async ({ params: { digest } }: Props) => { const [publication, entry] = await Promise.all([getPublication(), getEntry(digest)]) if (!entry) return notFound() const body = String( await unified() .use(remarkParse) // Parse markdown .use(highlightCode, { theme: publication.theme.colorMode == 'DARK' ? 'dark' : 'light' }) // Highlight code .use(remarkStringify) // Serialize markdown .process(entry.body) ) return ( ) } export default Article ================================================ FILE: src/app/api/link-preview/route.ts ================================================ import metascraper from 'metascraper' import urlFetcher from 'metascraper-url' import langFetcher from 'metascraper-lang' import dateFetcher from 'metascraper-date' import logoFetcher from 'metascraper-logo' import titleFetcher from 'metascraper-title' import imageFetcher from 'metascraper-image' import authorFetcher from 'metascraper-author' import { Metadata } from '@/types/link-preview' import clearbitFetcher from 'metascraper-clearbit' import { calculateImageSize } from '@/utils/images' import publisherFetcher from 'metascraper-publisher' import faviconFetcher from 'metascraper-logo-favicon' import { NextRequest, NextResponse } from 'next/server' import descriptionFetcher from 'metascraper-description' const scraper = metascraper([ titleFetcher(), descriptionFetcher(), langFetcher(), authorFetcher(), publisherFetcher(), imageFetcher(), dateFetcher(), urlFetcher(), logoFetcher(), clearbitFetcher(), faviconFetcher(), ]) export async function GET(request: NextRequest): Promise { const url = request.nextUrl.searchParams.get('url') if (!url) return NextResponse.json({ error: 'Missing url parameter' }, { status: 422 }) return NextResponse.json(await getMeta(url)) } const getMeta = async (url: string): Promise => { const html = await fetch(url).then(res => res.text()) const meta = await scraper({ html, url }) await Promise.all( ['image', 'logo'].map(async key => { if (!meta[key]) return // @ts-ignore meta[key] = { url: meta[key], ...(await calculateImageSize(meta[key]!)), } }) ) return meta as Metadata } ================================================ FILE: src/app/api/nft-data/route.ts ================================================ import ERC721 from '@/data/ERC721' import { NextRequest, NextResponse } from 'next/server' import { mainnet, base, optimism, zora, polygon } from 'viem/chains' import { createPublicClient, http, getContract, extractChain } from 'viem' export async function GET(request: NextRequest): Promise { const chainId = request.nextUrl.searchParams.get('chain') const tokenId = request.nextUrl.searchParams.get('tokenId') as bigint | null const contractAddress = request.nextUrl.searchParams.get('contract') as `0x${string}` | null if (!chainId || !contractAddress || !tokenId) { return NextResponse.json({ error: 'Missing chain, contract or tokendId parameters' }, { status: 400 }) } const chain = extractChain({ // @ts-ignore id: parseInt(chainId), chains: [mainnet, base, optimism, zora, polygon], }) const contract = getContract({ abi: ERC721, address: contractAddress, client: createPublicClient({ chain, transport: http() }), }) const token = await fetch(buildURL(await contract.read.tokenURI([tokenId]))).then(res => res.json()) return NextResponse.json({ ...token, tokenId, contractAddress, image: buildURL(token.image), mimeType: await getMimeType(buildURL(token.image)), }) } const buildURL = (rawURL: string): string => { const url = new URL(rawURL) switch (url.protocol) { case 'ipfs:': return `https://ipfs.io/ipfs/${url.hostname}${url.pathname}` case 'ar:': case 'arweave:': return `https://arweave.net/${url.hostname}${url.pathname}` default: return rawURL } } const getMimeType = async (url: string): Promise => { return fetch(url, { method: 'HEAD' }).then(res => res.headers.get('Content-Type')) } ================================================ FILE: src/app/error.tsx ================================================ 'use client' import Link from 'next/link' import { FC, useEffect } from 'react' const InternalError: FC = ({ error }: { error: Error & { digest?: string } }) => { useEffect(() => { console.error(error) }, [error]) return (

Internal Error

Return Home
) } export default InternalError ================================================ FILE: src/app/feed.xml/route.ts ================================================ import RSS from 'rss' import { unified } from 'unified' import remarkParse from 'remark-parse' import getEntries from '@/data/entries' import remark2rehype from 'remark-rehype' import rehypeStringify from 'rehype-stringify' import getPublication from '@/data/publication' import { NextRequest, NextResponse } from 'next/server' import highlightMarkdown from '@/utils/highlightMarkdown' export async function GET(request: NextRequest): Promise { const host = request.headers.get('host') let [publication, entries] = await Promise.all([getPublication(), getEntries()]) const markdownRenderer = unified() .use(remarkParse) // Parse markdown .use(highlightMarkdown, { theme: 'light' }) // Highlight code, doesn't work on production yet .use(remark2rehype, { allowDangerousHtml: true }) // Convert to HTML .use(rehypeStringify) // Serialize HTML const feed = new RSS({ title: publication.displayName, description: publication.description || 'A Mirror publication', image_url: publication?.headerImage?.url, managingEditor: publication.members.length == 1 ? publication.members[0].displayName : publication.displayName, webMaster: 'MirrorXYZ', ttl: 1 * 60, // cache for an hour site_url: `https://${host}/`, generator: 'RSS for Mirror, by Miguel Piedrafita', feed_url: `https://${host}/feed.xml`, }) await Promise.all( entries.map(async entry => { const body = String(await markdownRenderer.process(entry.body)) feed.item({ title: entry.title, guid: entry.digest, url: `https://${host}/${entry.digest}`, date: new Date(entry.timestamp * 1000).toISOString(), description: body, }) }) ) return new NextResponse(feed.xml({ indent: true }), { headers: { 'Content-Type': 'application/rss+xml', }, }) } ================================================ FILE: src/app/layout.tsx ================================================ import '@/styles/style.css' import Link from 'next/link' import { Metadata } from 'next' import { Inter } from 'next/font/google' import { SetTheme } from '@/context/theme' import getPublication from '@/data/publication' const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }) export async function generateMetadata(): Promise { const publication = await getPublication() return { title: `${publication.displayName} — Mirror`, description: publication.description, openGraph: { title: `${publication.displayName} — Mirror`, description: publication.description, images: [{ url: publication.avatarURL }], }, twitter: { card: 'summary', description: publication.description, images: [{ url: publication.avatarURL }], title: `${publication.displayName} — Mirror`, }, alternates: { types: { 'application/rss+xml': '/feed.xml', }, }, } } const RootLayout = async ({ children }: { children: React.ReactNode }) => { const publication = await getPublication() return (

{publication.displayName}

ENS

{publication.ens}

{children}
) } export default RootLayout ================================================ FILE: src/app/not-found.tsx ================================================ import { FC } from 'react' import getPublication from '@/data/publication' import LinkButton from '@/components/LinkButton' const PageNotFound: FC = async () => { const publication = await getPublication() return (

Page Not Found

Return Home
) } export default PageNotFound ================================================ FILE: src/app/page.tsx ================================================ import Link from 'next/link' import getEntries from '@/data/entries' import { getExcerpt } from '@/utils/excerpt' import { format as timeago } from 'timeago.js' import getPublication from '@/data/publication' import LinkButton from '@/components/LinkButton' import { SetImageSizes } from '@/context/image_sizes' import ReactMarkdown, { Components } from 'react-markdown' import { BlockQuote, LinkOrEmbed, Block, Image } from '@/utils/markdown' export const revalidate = 5 * 60 // refresh page index every 5 minutes const components: Components = { img: Image, a: LinkOrEmbed, blockquote: BlockQuote, p: Block, } const Index = async () => { const [publication, entries] = await Promise.all([getPublication(), getEntries()]) return (
{entries.map(entry => (
{entry.title}
{publication.members.map(contributor => (

{contributor.displayName}

{contributor.address.substr(0, 6)}
))}
{getExcerpt(entry.body)}
{entry.body.split('\n\n').length > 4 && ( Continue Reading )}
))} {entries.length === 0 && (

Patience

)}
) } export default Index ================================================ FILE: src/app/post/[slug]/route.ts ================================================ import getEntries from '@/data/entries' import { notFound, redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' export async function GET( request: NextRequest, { params: { slug } }: { params: { slug: string } } ): Promise { const entry = (await getEntries()).filter(entry => entry.slug === slug)?.[0] if (!entry) return notFound() return redirect(`/${entry.digest}`) } ================================================ FILE: src/app/posts.json/route.ts ================================================ import getEntries from '@/data/entries' import { NextRequest, NextResponse } from 'next/server' export async function GET(request: NextRequest): Promise { const host = request.headers.get('host') const entries = (await getEntries()).map(entry => ({ ...entry, url: `https://${host}/post/${entry.slug}` })) return NextResponse.json(entries) } ================================================ FILE: src/components/EntryLink.tsx ================================================ 'use client' import Link from 'next/link' import { useTheme } from '@/context/theme' import { FC, PropsWithChildren } from 'react' type Props = PropsWithChildren<{ href: string; className?: string }> const EntryLink: FC = ({ href, children, className }) => { const { accent } = useTheme() className = className ? className : getClass(accent) if (href.startsWith('/') || (typeof window !== 'undefined' && href.startsWith(window.location.origin))) { return ( {children} ) } return ( // eslint-disable-next-line react/jsx-no-target-blank {children} ) } const getClass = (accentColor: string): string => { switch (accentColor.toLowerCase()) { case 'purple': return '!text-fuchsia-400' case 'pink': return '!text-red-500' case 'red': return '!text-red-500' case 'orange': return '!text-orange-400' case 'yellow': return '!text-yellow-400' case 'teal': return '!text-cyan-400' case 'blue': return '!text-blue-500' case 'indigo': return '!text-indigo-400' case 'green': return '!text-emerald-400' case 'foreground': return '!text-white' default: return '!text-blue-400' } } export default EntryLink ================================================ FILE: src/components/LinkButton.tsx ================================================ import Link, { LinkProps } from 'next/link' import { FC, PropsWithChildren } from 'react' type Props = { accentColor: string } & LinkProps const LinkButton: FC> = ({ href, children, accentColor, ...props }) => { return ( {children} ) } const getClasses = (accentColor: string): string => { switch (accentColor.toLowerCase()) { case 'yellow': return 'bg-yellow-100 dark:bg-yellow-400 text-yellow-500 dark:text-yellow-300 ring-yellow-100 dark:ring-yellow-400' case 'purple': return 'bg-fuchsia-100 dark:bg-fuchsia-400 text-fuchsia-500 dark:text-fuchsia-300 ring-fuchsia-100 dark:ring-fuchsia-400' case 'pink': case 'red': return 'bg-red-100 dark:bg-red-400 text-red-500 dark:text-red-300 ring-red-100 dark:ring-red-400' case 'orange': return 'bg-orange-100 dark:bg-orange-400 text-orange-500 dark:text-orange-300 ring-orange-100 dark:ring-orange-400' case 'teal': return 'bg-cyan-100 dark:bg-cyan-400 text-cyan-500 dark:text-cyan-300 ring-cyan-100 dark:ring-cyan-400' case 'indigo': return 'bg-indigo-100 dark:bg-indigo-400 text-indigo-500 dark:text-indigo-300 ring-indigo-100 dark:ring-indigo-400' case 'green': return 'bg-emerald-100 dark:bg-emerald-400 text-emerald-500 dark:text-emerald-300 ring-emerald-100 dark:ring-emerald-400' case 'foreground': return 'bg-white text-white ring-white' case 'blue': default: return 'bg-blue-100 dark:bg-blue-400 text-blue-500 dark:text-blue-300 ring-blue-100 dark:ring-blue-400' } } export default LinkButton ================================================ FILE: src/components/NFT.tsx ================================================ 'use client' import useSWR from 'swr' import { useEffect, useRef, useState } from 'react' const NFT = ({ data: { name, image, contractAddress, tokenId, mimeType } }) => { return (

{name}

{image && mimeType && (
)}
) } const Loading = () => (
) const TextMedia = ({ media }) => { const [content, setContent] = useState(null) useEffect(() => { fetch(media) .then(r => r.text()) .then(r => setContent(r)) }, []) return
{content}
} const VideoMedia = ({ media }) => { const videoRef = useRef(null) const [isMuted, setMuted] = useState(true) useEffect(() => { if (!videoRef.current) return videoRef.current.muted = isMuted }, [isMuted]) return (
) } const AudioMedia = ({ media }) =>