From 1dcabdf6db0dad572fcfc7cf7008c62e3f5d08dd Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Mon, 4 May 2026 00:52:45 -0400 Subject: [PATCH] Add metadata generation for hashtag pages and create dynamic image response Co-authored-by: Copilot --- src/app/api/og/hashtag/[tag]/route.tsx | 152 +++++++++++++++++++++++++ src/app/hashtag/[tag]/page.tsx | 47 ++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/app/api/og/hashtag/[tag]/route.tsx diff --git a/src/app/api/og/hashtag/[tag]/route.tsx b/src/app/api/og/hashtag/[tag]/route.tsx new file mode 100644 index 0000000..25fe32c --- /dev/null +++ b/src/app/api/og/hashtag/[tag]/route.tsx @@ -0,0 +1,152 @@ +import { ImageResponse } from 'next/og' +import { prisma } from '@/lib/prisma' + +export const runtime = 'nodejs' + +const W = 1200 +const H = 630 +const CHART_X = 60 +const CHART_Y = 200 +const CHART_W = W - 120 +const CHART_H = 220 + +function buildPolyline(prices: number[]): string { + if (prices.length < 2) return '' + const min = Math.min(...prices) + const max = Math.max(...prices) + const range = max - min || 1 + return prices + .map((p, i) => { + const x = CHART_X + (i / (prices.length - 1)) * CHART_W + const y = CHART_Y + CHART_H - ((p - min) / range) * CHART_H + return `${x},${y}` + }) + .join(' ') +} + +export async function GET( + _req: Request, + { params }: { params: { tag: string } }, +) { + const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') + + const hashtag = await prisma.hashtag.findUnique({ + where: { tag }, + select: { + displayTag: true, + currentPrice: true, + isActive: true, + priceHistory: { + orderBy: { recordedAt: 'desc' }, + take: 48, + select: { price: true }, + }, + }, + }) + + const displayTag = hashtag?.displayTag ?? tag + const price = hashtag?.currentPrice ?? 0.25 + const prices = (hashtag?.priceHistory ?? []).map((p) => p.price).reverse() + const prevPrice = prices.length >= 2 ? prices[0] : null + const changePct = prevPrice && prevPrice > 0 + ? ((price - prevPrice) / prevPrice) * 100 + : null + const trending = changePct === null ? null : changePct >= 0 + const lineColor = trending === null ? '#6366f1' : trending ? '#34d399' : '#f87171' + const changeStr = changePct === null + ? '' + : `${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%` + + const priceStr = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price) + + const polyline = buildPolyline(prices) + + return new ImageResponse( + ( +
+ {/* Header row */} +
+
+
HashEx
+
+ #{displayTag} +
+
+
+
{priceStr}
+ {changeStr && ( +
+ {changeStr} +
+ )} + {!hashtag?.isActive && ( +
inactive
+ )} +
+
+ + {/* Sparkline */} + {prices.length >= 2 && ( + + {/* Subtle grid line at mid-price */} + + + + )} + + {/* Footer */} +
+ {new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host} + Trade hashtags like stocks +
+
+ ), + { width: W, height: H }, + ) +} diff --git a/src/app/hashtag/[tag]/page.tsx b/src/app/hashtag/[tag]/page.tsx index 1f7b0eb..61b5f8c 100644 --- a/src/app/hashtag/[tag]/page.tsx +++ b/src/app/hashtag/[tag]/page.tsx @@ -18,6 +18,8 @@ const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10) const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10) +import type { Metadata } from 'next' + export const dynamic = 'force-dynamic' interface Props { @@ -25,6 +27,51 @@ interface Props { searchParams: { fund?: string } } +export async function generateMetadata({ params }: Props): Promise { + const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') + const hashtag = await prisma.hashtag.findUnique({ + where: { tag }, + select: { + displayTag: true, + currentPrice: true, + isActive: true, + priceHistory: { orderBy: { recordedAt: 'desc' }, take: 2, select: { price: true } }, + }, + }) + + const displayTag = hashtag?.displayTag ?? tag + const price = hashtag?.currentPrice ?? 0 + const prevPrice = hashtag?.priceHistory[1]?.price ?? null + const changePct = prevPrice && prevPrice > 0 + ? ((price - prevPrice) / prevPrice) * 100 + : null + const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 }).format(price) + const changeStr = changePct !== null + ? ` ${changePct >= 0 ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%` + : '' + const status = hashtag?.isActive === false ? ' · inactive' : '' + + const title = `#${displayTag} — ${priceStr}${changeStr}` + const description = `Trade #${displayTag} on HashEx. Current price: ${priceStr}${changeStr}${status}. Prices driven by real Mastodon activity.` + const imageUrl = `/api/og/hashtag/${encodeURIComponent(tag)}` + + return { + title, + description, + openGraph: { + title, + description, + images: [{ url: imageUrl, width: 1200, height: 630, alt: `#${displayTag} price chart` }], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [imageUrl], + }, + } +} + export default async function HashtagPage({ params, searchParams }: Props) { const session = await getServerSession(authOptions) const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')