SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.4 KB · 116 lines tsx
Raw Blame History
1import { ImageResponse } from "next/og";2import { api } from "@/lib/api";3import { fmtInt, fmtMw } from "@/lib/format";45/**6 * Shared Open Graph template (1200×630) — dark ink, thin world-grid texture, mark + wordmark,7 * title/tagline, stats strip. Entity pages reuse `renderOg({ title, subtitle, stats, eyebrow })`.8 * Satori subset of CSS only: flex layout, no grid, no CSS vars.9 */1011export const OG_SIZE = { width: 1200, height: 630 };12export const OG_CONTENT_TYPE = "image/png";1314const INK = "#0b0f14";15const PAPER = "#eceae5";16const MUTED = "#8a9099";17const ACCENT = "#e8763a";18const HAIR = "rgba(236,234,229,0.08)";1920export interface OgStat {21  label: string;22  value: string;23}2425export interface OgProps {26  eyebrow?: string;27  title: string;28  subtitle?: string;29  stats?: OgStat[];30  footer?: string;31}3233function Mark({ size }: { size: number }) {34  return (35    <svg width={size} height={size} viewBox="0 0 32 32" fill="none">36      <circle cx="15" cy="16" r="12" stroke={PAPER} strokeWidth="2.2" />37      <path d="M15 4c-3.2 2.9-4.8 6.9-4.8 12s1.6 9.1 4.8 12c3.2-2.9 4.8-6.9 4.8-12S18.2 6.9 15 4Z" stroke={PAPER} strokeWidth="1.4" />38      <path d="M4.6 10.6h20.8M4.6 21.4h20.8" stroke={PAPER} strokeWidth="2" strokeLinecap="round" />39      <path d="M3 16h28" stroke={ACCENT} strokeWidth="2.6" strokeLinecap="round" />40    </svg>41  );42}4344function Graticule() {45  const cols = Array.from({ length: 25 }, (_, i) => i * 50);46  const rows = Array.from({ length: 13 }, (_, i) => i * 50);47  return (48    <div style={{ position: "absolute", inset: 0, display: "flex" }}>49      {cols.map((x) => (50        <div key={`c${x}`} style={{ position: "absolute", left: x, top: 0, width: 1, height: 630, background: HAIR }} />51      ))}52      {rows.map((y) => (53        <div key={`r${y}`} style={{ position: "absolute", top: y, left: 0, height: 1, width: 1200, background: HAIR }} />54      ))}55      {/* meridian arcs as thin ellipses */}56      {[220, 420, 640].map((rx, i) => (57        <div key={i} style={{ position: "absolute", left: 900 - rx, top: 315 - 420, width: rx * 2, height: 840, borderRadius: "50%", border: `1px solid ${HAIR}` }} />58      ))}59      <div style={{ position: "absolute", inset: 0, background: "linear-gradient(90deg, rgba(11,15,20,0.9) 0%, rgba(11,15,20,0.4) 55%, rgba(11,15,20,0) 100%)" }} />60    </div>61  );62}6364export function OgTemplate({ eyebrow, title, subtitle, stats, footer }: OgProps) {65  return (66    <div style={{ width: 1200, height: 630, display: "flex", flexDirection: "column", background: INK, color: PAPER, fontFamily: "Inter, sans-serif", position: "relative", padding: 64 }}>67      <Graticule />68      <div style={{ display: "flex", alignItems: "center", gap: 18, position: "relative" }}>69        <Mark size={56} />70        <div style={{ display: "flex", alignItems: "baseline", fontSize: 40, letterSpacing: -1 }}>71          <span style={{ fontWeight: 600 }}>DataCenter</span>72          <span style={{ fontWeight: 400, color: "#b9b6ae" }}>Index</span>73          <div style={{ width: 4, height: 24, background: ACCENT, marginLeft: 12 }} />74        </div>75      </div>76      <div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", position: "relative", paddingRight: 240 }}>77        {eyebrow && <div style={{ fontSize: 20, letterSpacing: 3, textTransform: "uppercase", color: MUTED, marginBottom: 14 }}>{eyebrow}</div>}78        <div style={{ fontSize: title.length > 40 ? 52 : 64, fontWeight: 600, letterSpacing: -2, lineHeight: 1.05 }}>{title}</div>79        {subtitle && <div style={{ fontSize: 26, color: "#b9b6ae", marginTop: 18, lineHeight: 1.3 }}>{subtitle}</div>}80      </div>81      <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", position: "relative", borderTop: `1px solid rgba(236,234,229,0.18)`, paddingTop: 22 }}>82        <div style={{ display: "flex", gap: 40 }}>83          {(stats ?? []).map((s, i) => (84            <div key={i} style={{ display: "flex", flexDirection: "column" }}>85              <div style={{ fontSize: 32, fontWeight: 600, letterSpacing: -1 }}>{s.value}</div>86              <div style={{ fontSize: 13, letterSpacing: 1.5, textTransform: "uppercase", color: MUTED, marginTop: 3 }}>{s.label}</div>87            </div>88          ))}89        </div>90        <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", fontSize: 18, color: MUTED }}>91          <span>{footer ?? "www.datacenterindex.io"}</span>92          <span style={{ fontSize: 13, letterSpacing: 2, textTransform: "uppercase", marginTop: 4 }}>published figures · sourced · versioned</span>93        </div>94      </div>95    </div>96  );97}9899/** Live stats for the default share image, with a graceful fallback when the API is down. */100export async function ogDashboardStats(): Promise<OgStat[]> {101  const r = await api.dashboard();102  if (!r.ok || !r.data?.stats) return [{ label: "Global coverage", value: "Facilities · MW · Countries" }];103  const s = r.data.stats;104  return [105    { label: "Facilities indexed", value: fmtInt(s.facilities) },106    { label: "Known operational MW", value: fmtMw(s.knownOperationalMw) },107    { label: "Under construction (≈)", value: fmtMw(s.constructionMw) },108    { label: "Countries", value: fmtInt(s.countries) },109    { label: "Events · 24 h", value: fmtInt(s.events24h) },110  ];111}112113export function renderOg(props: OgProps): ImageResponse {114  return new ImageResponse(<OgTemplate {...props} />, { ...OG_SIZE });115}116