/** @jsxImportSource react */ /** * Pure OG image JSX components — no DB, Hono, or metrics imports. * Used by the OG render worker to generate images off the main thread. */ import type { ReactNode } from "react"; import type { BookOgCardProps, StatsOgCardProps, ProfileOgCardProps, LabeledCoverCardProps, MarketingOgCardProps, AppOgCardProps, } from "./types"; // ─── Constants ─────────────────────────────────────────────────────────────── export const OG_WIDTH = 1200; export const OG_HEIGHT = 630; export const COLORS = { bg: "#f9eabc", // sand background card: "#fef9c3", // yellow-50 border: "#e5d5a0", // sand-dark text: "#1c1917", // stone-900 textMuted: "#78716c", // stone-500 primary: "#d97706", // amber-600 primaryLight: "#fef3c7", // amber-100 white: "#ffffff", }; export const OG_RENDER_OPTIONS = { width: OG_WIDTH, height: OG_HEIGHT, format: "webp" as const, }; // ─── Shared UI components ──────────────────────────────────────────────────── /** SVG star — avoids missing-glyph boxes since Geist has no ★ */ function StarIcon({ filled, size = 28 }: { filled: boolean; size?: number }) { return ( ); } function StarRating({ rating, max = 5, size = 28, }: { rating: number; max?: number; size?: number; }) { const filled = Math.round(rating); return (
{Array.from({ length: max }, (_, i) => ( ))}
); } /** BookHive logo mark using the sidebar book SVG */ function BrandMark({ size = 32 }: { size?: number }) { const h = size; const w = Math.round(size * (532 / 688)); return (
{/* Book body */} {/* Book pages (cream interior) */} {/* Spine */} BookHive
); } /** 3×2 grid of book covers filling the available space */ function CoverGrid({ covers }: { covers: string[] }) { return (
{covers.slice(0, 6).map((url, i) => ( ))}
); } /** Left sidebar + cover grid layout shared by ProfileOgCard and LabeledCoverCard */ function SidebarLayout({ sidebarWidth, sidebarPadding, children, covers, }: { sidebarWidth: number; sidebarPadding: string; children: ReactNode; covers: string[]; }) { return (
{children}
{covers.length > 0 && }
); } // ─── OG card components ────────────────────────────────────────────────────── export function BookOgCard({ title, authors, coverUrl, rating, ratingsCount, seriesTitle, seriesPosition, publicationYear, pageCount, readerCount, }: BookOgCardProps) { const shortTitle = title.length > 52 ? title.slice(0, 49) + "…" : title; const authorLine = authors.length === 1 ? authors[0]! : authors.slice(0, 2).join(", ") + (authors.length > 2 ? " & more" : ""); // rating is stored as score × 1000 (e.g. 4520 = 4.52 / 5) const displayRating = rating != null ? rating / 1000 : null; // Build series line e.g. "Book 3 in The Stormlight Archive" const seriesLine = seriesTitle ? seriesPosition ? `Book ${seriesPosition} in ${seriesTitle.length > 30 ? seriesTitle.slice(0, 27) + "…" : seriesTitle}` : seriesTitle.length > 35 ? seriesTitle.slice(0, 32) + "…" : seriesTitle : null; // Build metadata line e.g. "2017 · 1,232 pages" const metaParts: string[] = []; if (publicationYear && publicationYear > 0) metaParts.push(String(publicationYear)); if (pageCount && pageCount > 0) metaParts.push(`${pageCount.toLocaleString()} pages`); const metaLine = metaParts.length > 0 ? metaParts.join(" · ") : null; return (
{coverUrl && ( )} {/* Content panel: justify-between so BrandMark anchors to bottom */}
{shortTitle}
by {authorLine}
{seriesLine && (
{seriesLine}
)} {metaLine && (
{metaLine}
)} {displayRating != null && (
{displayRating.toFixed(1)} {ratingsCount != null && ` · ${ratingsCount.toLocaleString()} ratings`}
)} {readerCount > 0 && (
{readerCount.toLocaleString()} reader{readerCount !== 1 ? "s" : ""} on BookHive
)}
); } export function StatsOgCard({ handle, displayName, avatarUrl, year, booksCount, averageRating, topGenre, pagesRead, booksPerMonth, firstBook, lastBook, longestBook, }: StatsOgCardProps) { const name = displayName || `@${handle}`; const truncTitle = (t: string, max: number) => (t.length > max ? t.slice(0, max - 1) + "…" : t); return (
{/* Left panel: avatar + identity + branding */}
{avatarUrl && ( )}
{name}
@{handle}
{/* Right panel: stats */}
{booksCount} books read in {year}
{averageRating != null && (
{averageRating.toFixed(1)}
avg rating
)} {pagesRead > 0 && (
{pagesRead.toLocaleString()} pages read
)} {booksPerMonth != null && (
{booksPerMonth.toFixed(1)} books/mo
)} {topGenre && (
{topGenre} top genre
)}
{/* Bookends: first & last book of the year */} {firstBook && lastBook && (
{[ { label: "First read", book: firstBook }, { label: "Last read", book: lastBook }, ].map(({ label, book }) => (
{book.coverUrl && ( )}
{label} {truncTitle(book.title, 22)}
))}
)} {longestBook && (
Longest: {truncTitle(longestBook.title, 28)} ({longestBook.pageCount.toLocaleString()}{" "} pages)
)}
); } export function ProfileOgCard({ handle, displayName, avatarUrl, bio, totalBooks, booksThisYear, currentlyReading, recentCovers, topGenres, }: ProfileOgCardProps) { const name = displayName || `@${handle}`; const shortBio = bio ? (bio.length > 50 ? bio.slice(0, 47) + "…" : bio) : null; const bookCountLabel = booksThisYear > 0 ? `${totalBooks} books · ${booksThisYear} this year` : `${totalBooks} books on BookHive`; return (
{avatarUrl && ( )}
{name}
@{handle}
{shortBio && (
{shortBio}
)}
{totalBooks}
{bookCountLabel}
{currentlyReading && (
Currently reading
{currentlyReading.length > 30 ? currentlyReading.slice(0, 27) + "…" : currentlyReading}
)}
{topGenres.length > 0 && (
{topGenres.slice(0, 4).map((g) => (
{g.genre}
))}
)}
); } export function LabeledCoverCard({ label, name, totalBooks, covers, avgRating, readerCount, }: LabeledCoverCardProps) { const shortName = name.length > 28 ? name.slice(0, 25) + "…" : name; // avgRating is stored as score × 1000 (e.g. 4520 = 4.52 / 5) const displayRating = avgRating != null ? avgRating / 1000 : null; return (
{label}
{shortName}
{totalBooks.toLocaleString()} book{totalBooks !== 1 ? "s" : ""} on BookHive
{displayRating != null && (
{displayRating.toFixed(1)} avg rating
)} {readerCount != null && readerCount > 0 && (
{readerCount.toLocaleString()} reader{readerCount !== 1 ? "s" : ""}
)}
); } export function MarketingOgCard({ origin }: MarketingOgCardProps) { return (
{/* Left: logo panel */}
{/* Right: text panel */}
Reading is better together
Track your books, connect with friends, and discover your next favourite read on an open, social platform.
); } export function AppOgCard({ origin }: AppOgCardProps) { return (
{/* Left: mascot + phone mockup hint */}
{/* Right: text + App Store badge */}
BookHive for iPhone
Manage, organize, and review your books anywhere. Follow friends, leave comments, and discover your next great read — now on iOS.
); }