Add metadata generation for hashtag pages and create dynamic image response
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m38s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m38s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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<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) {
|
||||
const session = await getServerSession(authOptions)
|
||||
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
||||
|
||||
Reference in New Issue
Block a user