From fc49abf45c5028c0508f3da7a7c2f7597d324d68 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 11:11:35 +0000 Subject: [PATCH 1/2] [#487] Redesign story OG image with moleskine notebook aesthetic Restyle the dynamic OG image route with notebook-page design: ruled lines, red margin, cream paper background, Lora serif font loaded from Google Fonts, genre badge, price display, and PlotLink branding. Matches the app's moleskine design language. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/og/route.tsx | 240 +++++++++++++++++++---- 1 file changed, 197 insertions(+), 43 deletions(-) diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx index 3477dc10..9ba1cdbe 100644 --- a/src/app/story/[storylineId]/og/route.tsx +++ b/src/app/story/[storylineId]/og/route.tsx @@ -4,10 +4,26 @@ import { createServerClient, type Storyline } from "../../../../../lib/supabase" import { getTokenPrice } from "../../../../../lib/price"; import { lookupByAddress } from "../../../../../lib/farcaster"; import { RESERVE_LABEL, STORY_FACTORY } from "../../../../../lib/contracts/constants"; +import { formatPrice } from "../../../../../lib/format"; import { truncateAddress } from "../../../../../lib/utils"; export const runtime = "edge"; +async function loadFont(): Promise { + try { + const res = await fetch( + "https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap", + ); + const css = await res.text(); + const match = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)/); + if (!match?.[1]) return null; + const fontRes = await fetch(match[1]); + return fontRes.arrayBuffer(); + } catch { + return null; + } +} + export async function GET( _request: Request, { params }: { params: Promise<{ storylineId: string }> }, @@ -38,15 +54,26 @@ export async function GET( const sl = storyline as Storyline; - const [priceInfo, farcasterProfile] = await Promise.all([ + const [priceInfo, farcasterProfile, fontData] = await Promise.all([ sl.token_address ? getTokenPrice(sl.token_address as Address) : null, lookupByAddress(sl.writer_address).catch(() => null), + loadFont(), ]); const reserveLabel = RESERVE_LABEL; const priceDisplay = priceInfo - ? `${priceInfo.pricePerToken} ${reserveLabel}` + ? `${formatPrice(priceInfo.pricePerToken)} ${reserveLabel}` : null; + const authorName = farcasterProfile + ? `@${farcasterProfile.username}` + : truncateAddress(sl.writer_address); + const plotLabel = sl.plot_count === 1 ? "plot" : "plots"; + const titleDisplay = + sl.title.length > 70 ? `${sl.title.slice(0, 67)}...` : sl.title; + + const fonts = fontData + ? [{ name: "Lora", data: fontData, weight: 700 as const }] + : []; return new ImageResponse( ( @@ -55,67 +82,194 @@ export async function GET( width: "100%", height: "100%", display: "flex", - flexDirection: "column", - justifyContent: "space-between", - padding: "60px", - backgroundColor: "#E8DFD0", - color: "#2C1810", - fontFamily: "Georgia, serif", + backgroundColor: "#DDD3C2", + padding: "32px", + fontFamily: fontData ? "Lora" : "Georgia, serif", }} > - {/* Top: branding */} -
- PlotLink -
- - {/* Center: title */} + {/* Notebook page */}
+ {/* Red margin line */} +
+ + {/* Ruled lines */}
- {sl.title.length > 80 ? `${sl.title.slice(0, 77)}...` : sl.title} + {Array.from({ length: 16 }).map((_, i) => ( +
+ ))}
-
- {/* Bottom: metadata */} -
- by {farcasterProfile ? `@${farcasterProfile.username}` : truncateAddress(sl.writer_address)} - - {sl.plot_count} {sl.plot_count === 1 ? "plot" : "plots"} - - {priceDisplay && Price: {priceDisplay}} + {/* Content overlay */} +
+ {/* Top: PlotLink branding */} +
+
+ PlotLink +
+ {sl.genre && ( +
+ {sl.genre} +
+ )} +
+ + {/* Center: Story title */} +
+
40 ? "42px" : "52px", + fontWeight: 700, + color: "#2C1810", + lineHeight: 1.2, + display: "flex", + }} + > + {titleDisplay} +
+
+ by {authorName} +
+
+ + {/* Bottom: metadata bar */} +
+
+ + {sl.plot_count} {plotLabel} + + {priceDisplay && ( + + {priceDisplay} + + )} +
+
+ plotlink.xyz +
+
+
), { width: 1200, height: 630, + fonts, }, ); } From 525430b7263c5c1925ccf95aa0c7e6fa3cba2731 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 24 Mar 2026 11:14:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?[#487]=20Fix=20font=20loading=20=E2=80=94?= =?UTF-8?q?=20use=20TTF=20format=20for=20ImageResponse=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageResponse only supports ttf/otf/woff, not woff2. Fetch from Google Fonts without User-Agent to get truetype format. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/og/route.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx index 9ba1cdbe..2c2280c0 100644 --- a/src/app/story/[storylineId]/og/route.tsx +++ b/src/app/story/[storylineId]/og/route.tsx @@ -11,11 +11,15 @@ export const runtime = "edge"; async function loadFont(): Promise { try { + // Fetch with no User-Agent → Google returns TTF (truetype) format + // ImageResponse supports ttf/otf/woff but NOT woff2 const res = await fetch( - "https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap", + "https://fonts.googleapis.com/css2?family=Lora:wght@700&display=swap", ); const css = await res.text(); - const match = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)/); + const match = + css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]truetype['"]\)/) ?? + css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff['"]\)/); if (!match?.[1]) return null; const fontRes = await fetch(match[1]); return fontRes.arrayBuffer();