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_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(/^#+/, '')
|
||||||
|
|||||||
Reference in New Issue
Block a user