Add metadata generation for hashtag pages and create dynamic image response
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m38s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-04 00:52:45 -04:00
parent a03ab09d05
commit 1dcabdf6db
2 changed files with 199 additions and 0 deletions
+152
View File
@@ -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(
(
<div
style={{
width: W,
height: H,
background: '#0f0f17',
display: 'flex',
flexDirection: 'column',
padding: '60px',
fontFamily: 'sans-serif',
position: 'relative',
}}
>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 28, color: '#6366f1', marginBottom: 8 }}>HashEx</div>
<div style={{ fontSize: 72, fontWeight: 700, color: '#ffffff' }}>
#{displayTag}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff' }}>{priceStr}</div>
{changeStr && (
<div style={{ fontSize: 36, fontWeight: 600, color: lineColor, marginTop: 4 }}>
{changeStr}
</div>
)}
{!hashtag?.isActive && (
<div style={{ fontSize: 22, color: '#f97316', marginTop: 8 }}>inactive</div>
)}
</div>
</div>
{/* Sparkline */}
{prices.length >= 2 && (
<svg
width={W}
height={CHART_H + 40}
style={{ position: 'absolute', left: 0, top: 280 }}
>
{/* Subtle grid line at mid-price */}
<line
x1={CHART_X}
y1={CHART_Y + CHART_H / 2}
x2={CHART_X + CHART_W}
y2={CHART_Y + CHART_H / 2}
stroke="#1e1e2e"
strokeWidth={1}
/>
<polyline
points={polyline}
fill="none"
stroke={lineColor}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.9}
/>
</svg>
)}
{/* Footer */}
<div
style={{
position: 'absolute',
bottom: 40,
left: 60,
right: 60,
display: 'flex',
justifyContent: 'space-between',
color: '#475569',
fontSize: 22,
}}
>
<span>{new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host}</span>
<span>Trade hashtags like stocks</span>
</div>
</div>
),
{ width: W, height: H },
)
}
+47
View File
@@ -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_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 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' export const dynamic = 'force-dynamic'
interface Props { interface Props {
@@ -25,6 +27,51 @@ interface Props {
searchParams: { fund?: string } searchParams: { fund?: string }
} }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
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) { export default async function HashtagPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')