Brand: unique SVG mark (open ring C + graduated index I), header/footer wordmark, favicon (svg/ico/apple), web manifest, Open Graph / Twitter share images rendered server-side with site fonts (site default with live counts + cancer/gene/drug/biomarker/trial images)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
27 changed files +388 −4
added
apps/web/public/brand/icon-192.png
+0 −0
Binary file not shown.
added
apps/web/public/brand/icon-512.png
+0 −0
Binary file not shown.
added
apps/web/public/brand/logo-mark-dark.png
+0 −0
Binary file not shown.
added
apps/web/public/brand/logo-mark-dark.svg
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-labelledby="t"> | |
| 2 | + <title id="t">CancerIndex mark (for dark backgrounds)</title> | |
| 3 | + <circle cx="30" cy="32" r="24" fill="none" stroke="#ecece6" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/> | |
| 4 | + <line x1="50" y1="14" x2="50" y2="50" stroke="#6cc3c6" stroke-width="4.5" stroke-linecap="round"/> | |
| 5 | + <line x1="50" y1="16" x2="60" y2="16" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/> | |
| 6 | + <line x1="50" y1="24" x2="56" y2="24" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/> | |
| 7 | + <line x1="50" y1="32" x2="60" y2="32" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/> | |
| 8 | + <line x1="50" y1="40" x2="56" y2="40" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/> | |
| 9 | + <line x1="50" y1="48" x2="60" y2="48" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/> | |
| 10 | + <circle cx="30" cy="32" r="4.5" fill="#ecece6"/> | |
| 11 | +</svg> | |
added
apps/web/public/brand/logo-mark.png
+0 −0
Binary file not shown.
added
apps/web/public/brand/logo-mark.svg
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-labelledby="t"> | |
| 2 | + <title id="t">CancerIndex mark — an open ring (C) and an index scale (I)</title> | |
| 3 | + <!-- C: open ring, gap centred on the right --> | |
| 4 | + <circle cx="30" cy="32" r="24" fill="none" stroke="#1c1c1a" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/> | |
| 5 | + <!-- I: index scale with graduated ticks --> | |
| 6 | + <line x1="50" y1="14" x2="50" y2="50" stroke="#0f5f63" stroke-width="4.5" stroke-linecap="round"/> | |
| 7 | + <line x1="50" y1="16" x2="60" y2="16" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 8 | + <line x1="50" y1="24" x2="56" y2="24" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 9 | + <line x1="50" y1="32" x2="60" y2="32" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 10 | + <line x1="50" y1="40" x2="56" y2="40" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 11 | + <line x1="50" y1="48" x2="60" y2="48" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 12 | + <!-- nucleus: the one data point inside the ring --> | |
| 13 | + <circle cx="30" cy="32" r="4.5" fill="#1c1c1a"/> | |
| 14 | +</svg> | |
added
apps/web/public/brand/logo.svg
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64" role="img" aria-labelledby="t"> | |
| 2 | + <title id="t">CancerIndex — the global index of cancer</title> | |
| 3 | + <g> | |
| 4 | + <circle cx="30" cy="32" r="24" fill="none" stroke="#1c1c1a" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/> | |
| 5 | + <line x1="50" y1="14" x2="50" y2="50" stroke="#0f5f63" stroke-width="4.5" stroke-linecap="round"/> | |
| 6 | + <line x1="50" y1="16" x2="60" y2="16" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 7 | + <line x1="50" y1="24" x2="56" y2="24" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 8 | + <line x1="50" y1="32" x2="60" y2="32" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 9 | + <line x1="50" y1="40" x2="56" y2="40" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 10 | + <line x1="50" y1="48" x2="60" y2="48" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/> | |
| 11 | + <circle cx="30" cy="32" r="4.5" fill="#1c1c1a"/> | |
| 12 | + </g> | |
| 13 | + <text x="76" y="43" font-family="Newsreader, 'Iowan Old Style', Georgia, serif" font-size="34" font-weight="500" letter-spacing="-0.4" fill="#1c1c1a">Cancer<tspan fill="#0f5f63">Index</tspan></text> | |
| 14 | +</svg> | |
added
apps/web/src/app/apple-icon.png
+0 −0
Binary file not shown.
added
apps/web/src/app/biomarker/[slug]/opengraph-image.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getBiomarkerBySlug } from '@/lib/queries/biomarkers'; | |
| 3 | +import { humanize } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex biomarker page'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { | |
| 11 | + const { slug } = await params; | |
| 12 | + const b = await getBiomarkerBySlug(slug); | |
| 13 | + if (!b) return ogImage({ kicker: 'Biomarker', title: 'Biomarker not found', subtitle: slug }); | |
| 14 | + const genes = (b.gene_symbols ?? []).slice(0, 4).join(', '); | |
| 15 | + return ogImage({ | |
| 16 | + kicker: `Biomarker · ${humanize(b.kind)}${b.ncit_code ? ` · NCIt ${b.ncit_code}` : ''}`, | |
| 17 | + title: b.name, | |
| 18 | + subtitle: b.description ?? (genes ? `Anchor gene${genes.includes(',') ? 's' : ''}: ${genes}` : 'Curated biomarker with derived links to cancers, drugs, approvals and trials.'), | |
| 19 | + facts: genes ? [{ label: 'anchor genes', value: genes }] : [], | |
| 20 | + sourcesNote: 'NCIt (EVS) · CIViC · openFDA / Health Canada / EMA · ClinicalTrials.gov', | |
| 21 | + }); | |
| 22 | +} | |
added
apps/web/src/app/cancer/[slug]/opengraph-image.tsx
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getCancerBySlug, getCounters } from '@/lib/queries/cancers'; | |
| 3 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex cancer profile'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +/** Share image of a cancer page: name, entity type, NCIt code and the deterministic counters (never estimates). */ | |
| 11 | +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { | |
| 12 | + const { slug } = await params; | |
| 13 | + const c = await getCancerBySlug(slug); | |
| 14 | + if (!c) return ogImage({ kicker: 'Cancer', title: 'Entity not found', subtitle: slug }); | |
| 15 | + const k = await getCounters(c.id); | |
| 16 | + const facts = [ | |
| 17 | + k?.active_trial_count ? { label: 'active trials', value: fmtInt(k.active_trial_count) } : null, | |
| 18 | + k?.approved_drug_count ? { label: 'approved drugs', value: fmtInt(k.approved_drug_count) } : null, | |
| 19 | + k?.evidence_count ? { label: 'evidence items', value: fmtInt(k.evidence_count) } : null, | |
| 20 | + k?.publication_count_5y ? { label: 'publications · 5 y', value: fmtInt(k.publication_count_5y) } : null, | |
| 21 | + ].filter((f): f is { label: string; value: string } => !!f); | |
| 22 | + return ogImage({ | |
| 23 | + kicker: `${humanize(c.entity_type)}${c.primary_ncit_code ? ` · NCIt ${c.primary_ncit_code}` : ''}`, | |
| 24 | + title: c.canonical_name, | |
| 25 | + subtitle: c.description ?? (c.short_name && c.short_name !== c.canonical_name ? c.short_name : `Taxonomy, statistics, genomics, evidence, drugs, trials and rankings for ${c.canonical_name}.`), | |
| 26 | + facts, | |
| 27 | + sourcesNote: facts.length ? 'ClinicalTrials.gov · openFDA / Health Canada / EMA · CIViC · PubMed' : null, | |
| 28 | + }); | |
| 29 | +} | |
added
apps/web/src/app/drug/[slug]/opengraph-image.tsx
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getDrugBySlug } from '@/lib/queries/drugs'; | |
| 3 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex drug page'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { | |
| 11 | + const { slug } = await params; | |
| 12 | + const d = await getDrugBySlug(slug); | |
| 13 | + if (!d) return ogImage({ kicker: 'Drug', title: 'Drug not found', subtitle: slug }); | |
| 14 | + const facts = [ | |
| 15 | + d.approval_count ? { label: 'approval records', value: fmtInt(d.approval_count) } : null, | |
| 16 | + d.trial_count ? { label: 'registered trials', value: fmtInt(d.trial_count) } : null, | |
| 17 | + d.evidence_count ? { label: 'curated evidence items', value: fmtInt(d.evidence_count) } : null, | |
| 18 | + ].filter((f): f is { label: string; value: string } => !!f); | |
| 19 | + return ogImage({ | |
| 20 | + kicker: `Drug${d.kind ? ` · ${humanize(d.kind)}` : ''}${d.chembl_id ? ` · ${d.chembl_id}` : ''}`, | |
| 21 | + title: d.name, | |
| 22 | + subtitle: d.mechanism ?? d.description ?? 'Jurisdiction-aware approvals, curated evidence, development pipeline and clinical trials.', | |
| 23 | + facts, | |
| 24 | + sourcesNote: 'openFDA · Health Canada · EMA · CIViC · ClinicalTrials.gov', | |
| 25 | + }); | |
| 26 | +} | |
added
apps/web/src/app/favicon.ico
+0 −0
Binary file not shown.
added
apps/web/src/app/gene/[symbol]/opengraph-image.tsx
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getGeneBySymbol } from '@/lib/queries/genomics'; | |
| 3 | +import { fmtInt } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex gene page'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +export default async function Image({ params }: { params: Promise<{ symbol: string }> }) { | |
| 11 | + const { symbol } = await params; | |
| 12 | + const g = await getGeneBySymbol(symbol); | |
| 13 | + if (!g) return ogImage({ kicker: 'Gene', title: 'Gene not found', subtitle: symbol }); | |
| 14 | + const facts = [ | |
| 15 | + g.evidence_count ? { label: 'curated evidence items', value: fmtInt(g.evidence_count) } : null, | |
| 16 | + g.variant_count ? { label: 'variants', value: fmtInt(g.variant_count) } : null, | |
| 17 | + g.cohort_count ? { label: 'genomic cohorts', value: fmtInt(g.cohort_count) } : null, | |
| 18 | + ].filter((f): f is { label: string; value: string } => !!f); | |
| 19 | + return ogImage({ | |
| 20 | + kicker: `Gene${g.hgnc_id ? ` · ${g.hgnc_id}` : ''}${g.is_cancer_gene ? ' · cancer gene' : ''}`, | |
| 21 | + title: g.symbol, | |
| 22 | + subtitle: g.name ?? 'Curated cancer evidence, variants and cohort alteration frequencies.', | |
| 23 | + facts, | |
| 24 | + sourcesNote: 'HGNC · CIViC · ClinVar · GDC · cBioPortal', | |
| 25 | + }); | |
| 26 | +} | |
added
apps/web/src/app/icon.svg
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"> | |
| 2 | + <rect width="64" height="64" rx="12" fill="#fafaf7"/> | |
| 3 | + <circle cx="29" cy="32" r="21" fill="none" stroke="#1c1c1a" stroke-width="5" stroke-linecap="round" stroke-dasharray="91.6 131.9" transform="rotate(55 29 32)"/> | |
| 4 | + <line x1="48" y1="15" x2="48" y2="49" stroke="#0f5f63" stroke-width="5" stroke-linecap="round"/> | |
| 5 | + <line x1="48" y1="17" x2="57" y2="17" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/> | |
| 6 | + <line x1="48" y1="24.5" x2="54" y2="24.5" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/> | |
| 7 | + <line x1="48" y1="32" x2="57" y2="32" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/> | |
| 8 | + <line x1="48" y1="39.5" x2="54" y2="39.5" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/> | |
| 9 | + <line x1="48" y1="47" x2="57" y2="47" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/> | |
| 10 | + <circle cx="29" cy="32" r="4.5" fill="#1c1c1a"/> | |
| 11 | +</svg> | |
modified
apps/web/src/app/layout.tsx
+1 −1
@@ -18,7 +18,7 @@ export const metadata: Metadata = { | ||
| 18 | 18 | creator: AUTHOR_NAME, |
| 19 | 19 | robots: { index: true, follow: true }, |
| 20 | 20 | openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: 'Provenance-first oncology intelligence: every number linked to its source.' }, |
| 21 | − twitter: { card: 'summary', title: SITE_NAME, description: TAGLINE }, | |
| 21 | + twitter: { card: 'summary_large_image', title: SITE_NAME, description: TAGLINE, creator: '@spboucher' }, | |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | 24 | export const viewport: Viewport = { |
added
apps/web/src/app/manifest.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +import type { MetadataRoute } from 'next'; | |
| 2 | +import { SITE_NAME, TAGLINE } from '@/lib/site'; | |
| 3 | + | |
| 4 | +export default function manifest(): MetadataRoute.Manifest { | |
| 5 | + return { | |
| 6 | + name: `${SITE_NAME} — ${TAGLINE}`, | |
| 7 | + short_name: SITE_NAME, | |
| 8 | + description: 'Provenance-first oncology intelligence: epidemiology, genomics, biomarkers, drugs, approvals, trials and research across every recognized cancer entity.', | |
| 9 | + start_url: '/', | |
| 10 | + display: 'standalone', | |
| 11 | + background_color: '#fafaf7', | |
| 12 | + theme_color: '#0f5f63', | |
| 13 | + icons: [ | |
| 14 | + { src: '/brand/icon-192.png', sizes: '192x192', type: 'image/png' }, | |
| 15 | + { src: '/brand/icon-512.png', sizes: '512x512', type: 'image/png' }, | |
| 16 | + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }, | |
| 17 | + ], | |
| 18 | + }; | |
| 19 | +} | |
added
apps/web/src/app/opengraph-image.tsx
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getSiteCounts } from '@/lib/queries/stats'; | |
| 3 | +import { fmtInt } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex — the global index of cancer'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +/** Site-wide share image: live database counts (never estimates), refreshed hourly. */ | |
| 11 | +export default async function Image() { | |
| 12 | + const c = await getSiteCounts(); | |
| 13 | + const facts = [ | |
| 14 | + c.cancers ? { label: 'cancers indexed', value: fmtInt(c.cancers) } : null, | |
| 15 | + c.activeTrials ? { label: 'active trials', value: fmtInt(c.activeTrials) } : null, | |
| 16 | + c.approvedDrugs ? { label: 'approved drugs', value: fmtInt(c.approvedDrugs) } : null, | |
| 17 | + c.genes ? { label: 'genes', value: fmtInt(c.genes) } : null, | |
| 18 | + ].filter((f): f is { label: string; value: string } => !!f); | |
| 19 | + return ogImage({ | |
| 20 | + kicker: 'Global cancer intelligence', | |
| 21 | + title: 'Understand cancer through data.', | |
| 22 | + subtitle: 'Epidemiology, genomics, biomarkers, drugs, approvals, trials and research, cross-linked across every recognized cancer entity.', | |
| 23 | + facts, | |
| 24 | + sourcesNote: 'live database counts', | |
| 25 | + }); | |
| 26 | +} | |
added
apps/web/src/app/trial/[nct]/opengraph-image.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og'; | |
| 2 | +import { getTrialByNct } from '@/lib/queries/trials'; | |
| 3 | +import { fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export const alt = 'CancerIndex clinical trial page'; | |
| 6 | +export const size = OG_SIZE; | |
| 7 | +export const contentType = OG_CONTENT_TYPE; | |
| 8 | +export const revalidate = 3600; | |
| 9 | + | |
| 10 | +export default async function Image({ params }: { params: Promise<{ nct: string }> }) { | |
| 11 | + const { nct } = await params; | |
| 12 | + const t = await getTrialByNct(nct); | |
| 13 | + if (!t) return ogImage({ kicker: 'Clinical trial', title: 'Study not found', subtitle: nct }); | |
| 14 | + const facts = [ | |
| 15 | + t.phases.length ? { label: 'phase', value: t.phases.map(phaseLabel).join(' / ') } : null, | |
| 16 | + t.overall_status ? { label: 'status', value: humanize(t.overall_status) } : null, | |
| 17 | + t.enrollment_count != null ? { label: 'enrollment (n)', value: fmtInt(t.enrollment_count) } : null, | |
| 18 | + t.countries.length ? { label: 'countries', value: fmtInt(t.countries.length) } : null, | |
| 19 | + ].filter((f): f is { label: string; value: string } => !!f); | |
| 20 | + return ogImage({ | |
| 21 | + kicker: `Clinical trial · ${t.nct_id}${t.study_type ? ` · ${humanize(t.study_type)}` : ''}`, | |
| 22 | + title: t.brief_title, | |
| 23 | + subtitle: t.lead_sponsor ? `Lead sponsor: ${t.lead_sponsor}${t.lead_sponsor_class ? ` (${humanize(t.lead_sponsor_class)})` : ''}` : null, | |
| 24 | + facts, | |
| 25 | + sourcesNote: 'ClinicalTrials.gov · status and phase as posted', | |
| 26 | + }); | |
| 27 | +} | |
added
apps/web/src/assets/fonts/inter-400.woff
+0 −0
Binary file not shown.
added
apps/web/src/assets/fonts/inter-600.woff
+0 −0
Binary file not shown.
added
apps/web/src/assets/fonts/newsreader-500.woff
+0 −0
Binary file not shown.
added
apps/web/src/assets/fonts/newsreader-500i.woff
+0 −0
Binary file not shown.
added
apps/web/src/assets/fonts/plexmono-400.woff
+0 −0
Binary file not shown.
added
apps/web/src/components/layout/brand-mark.tsx
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +/** | |
| 2 | + * CancerIndex mark: an open ring (the "C", a cell) and a graduated index scale (the "I"). The ring | |
| 3 | + * and nucleus follow the text colour so the mark adapts to light and dark themes; the scale uses | |
| 4 | + * the accent token. Static SVG assets for external use live in /public/brand. | |
| 5 | + */ | |
| 6 | +export function BrandMark({ size = 28, className = '', title = 'CancerIndex' }: { size?: number; className?: string; title?: string }) { | |
| 7 | + return ( | |
| 8 | + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width={size} height={size} role="img" aria-label={title} className={className}> | |
| 9 | + <circle cx="30" cy="32" r="24" fill="none" stroke="currentColor" strokeWidth="4.5" strokeLinecap="round" strokeDasharray="104.7 150.8" transform="rotate(55 30 32)" /> | |
| 10 | + <line x1="50" y1="14" x2="50" y2="50" stroke="var(--color-accent)" strokeWidth="4.5" strokeLinecap="round" /> | |
| 11 | + <line x1="50" y1="16" x2="60" y2="16" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" /> | |
| 12 | + <line x1="50" y1="24" x2="56" y2="24" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" /> | |
| 13 | + <line x1="50" y1="32" x2="60" y2="32" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" /> | |
| 14 | + <line x1="50" y1="40" x2="56" y2="40" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" /> | |
| 15 | + <line x1="50" y1="48" x2="60" y2="48" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" /> | |
| 16 | + <circle cx="30" cy="32" r="4.5" fill="currentColor" /> | |
| 17 | + </svg> | |
| 18 | + ); | |
| 19 | +} | |
modified
apps/web/src/components/layout/site-footer.tsx
+5 −1
@@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Link from 'next/link'; |
| 2 | +import { BrandMark } from './brand-mark'; | |
| 2 | 3 | import { DISCLAIMER, FOOTER_NAV, CONTACT_EMAIL, AUTHOR_NAME, AUTHOR_URL, HOSTING_NAME, HOSTING_URL } from '@/lib/site'; |
| 3 | 4 | |
| 4 | 5 | export function SiteFooter() { |
@@ -7,7 +8,10 @@ export function SiteFooter() { | ||
| 7 | 8 | <div className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6"> |
| 8 | 9 | <div className="grid gap-6 md:grid-cols-[1.4fr_1fr]"> |
| 9 | 10 | <div> |
| 10 | − <p className="font-display text-lg">CancerIndex</p> | |
| 11 | + <p className="flex items-center gap-2 font-display text-lg"> | |
| 12 | + <BrandMark size={22} className="text-ink" /> | |
| 13 | + Cancer<span className="text-accent">Index</span> | |
| 14 | + </p> | |
| 11 | 15 | <p className="mt-2 max-w-2xl text-[13px] leading-relaxed text-ink-2">{DISCLAIMER}</p> |
| 12 | 16 | <p className="mt-2 max-w-2xl text-[12.5px] leading-relaxed text-ink-3"> |
| 13 | 17 | Every number links to its source and retrieval date. Derived metrics carry a formula version. AI-generated synthesis is not enabled in this phase; all content is database-grounded. |
modified
apps/web/src/components/layout/site-header.tsx
+6 −2
@@ -3,13 +3,17 @@ import { NAV } from '@/lib/site'; | ||
| 3 | 3 | import { CommandPalette } from './command-palette'; |
| 4 | 4 | import { MobileNav } from './mobile-nav'; |
| 5 | 5 | import { ThemeToggle } from './theme-toggle'; |
| 6 | +import { BrandMark } from './brand-mark'; | |
| 6 | 7 | |
| 7 | 8 | export function SiteHeader() { |
| 8 | 9 | return ( |
| 9 | 10 | <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur-sm" style={{ height: 'var(--ci-header-h)' }}> |
| 10 | 11 | <div className="mx-auto flex h-full max-w-[1400px] items-center gap-4 px-4 sm:px-6"> |
| 11 | − <Link href="/" className="flex items-baseline gap-2 no-underline"> | |
| 12 | − <span className="font-display text-[20px] font-medium tracking-tight text-ink">CancerIndex</span> | |
| 12 | + <Link href="/" className="flex items-center gap-2.5 no-underline" aria-label="CancerIndex home"> | |
| 13 | + <BrandMark size={26} className="shrink-0 text-ink" /> | |
| 14 | + <span className="font-display text-[20px] font-medium tracking-tight text-ink"> | |
| 15 | + Cancer<span className="text-accent">Index</span> | |
| 16 | + </span> | |
| 13 | 17 | <span className="hidden text-[11px] uppercase tracking-[0.08em] text-ink-3 md:inline">the global index of cancer</span> |
| 14 | 18 | </Link> |
| 15 | 19 | <nav aria-label="Primary" className="ml-2 hidden flex-1 items-center gap-4 lg:flex"> |
added
apps/web/src/lib/og.tsx
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +import { readFile } from 'node:fs/promises'; | |
| 2 | +import path from 'node:path'; | |
| 3 | +import { ImageResponse } from 'next/og'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Social-share images (Open Graph / Twitter cards), 1200 × 630, rendered server-side with the site | |
| 7 | + * fonts (WOFF copies in src/assets/fonts). Shared by the site default (`app/opengraph-image.tsx`) | |
| 8 | + * and the entity images (cancer, gene, drug, biomarker, trial). Content is limited to sourced facts | |
| 9 | + * passed in by the caller — never estimates. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export const OG_SIZE = { width: 1200, height: 630 } as const; | |
| 13 | +export const OG_CONTENT_TYPE = 'image/png'; | |
| 14 | + | |
| 15 | +const PAPER = '#fafaf7'; | |
| 16 | +const INK = '#1c1c1a'; | |
| 17 | +const INK2 = '#4a4a46'; | |
| 18 | +const INK3 = '#7b7b75'; | |
| 19 | +const RULE = '#dcdcd4'; | |
| 20 | +const ACCENT = '#0f5f63'; | |
| 21 | +const ACCENT_SOFT = '#e2eeee'; | |
| 22 | + | |
| 23 | +let fontCache: Promise<Array<{ name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' | 'italic' }>> | null = null; | |
| 24 | +let markCache: Promise<string> | null = null; | |
| 25 | + | |
| 26 | +function assetDir(): string { | |
| 27 | + return path.join(process.cwd(), 'src', 'assets', 'fonts'); | |
| 28 | +} | |
| 29 | + | |
| 30 | +async function loadFonts() { | |
| 31 | + if (!fontCache) { | |
| 32 | + fontCache = (async () => { | |
| 33 | + const dir = assetDir(); | |
| 34 | + const read = async (f: string) => { | |
| 35 | + const b = await readFile(path.join(dir, f)); | |
| 36 | + return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer; | |
| 37 | + }; | |
| 38 | + return [ | |
| 39 | + { name: 'Newsreader', data: await read('newsreader-500.woff'), weight: 500 as const, style: 'normal' as const }, | |
| 40 | + { name: 'Newsreader', data: await read('newsreader-500i.woff'), weight: 500 as const, style: 'italic' as const }, | |
| 41 | + { name: 'Inter', data: await read('inter-400.woff'), weight: 400 as const, style: 'normal' as const }, | |
| 42 | + { name: 'Inter', data: await read('inter-600.woff'), weight: 600 as const, style: 'normal' as const }, | |
| 43 | + ]; | |
| 44 | + })(); | |
| 45 | + } | |
| 46 | + return fontCache; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** The brand mark as a PNG data URI (satori renders <img>; SVG dash arrays are not supported). */ | |
| 50 | +async function loadMark(): Promise<string> { | |
| 51 | + if (!markCache) { | |
| 52 | + markCache = readFile(path.join(process.cwd(), 'public', 'brand', 'logo-mark.png')).then((b) => `data:image/png;base64,${b.toString('base64')}`); | |
| 53 | + } | |
| 54 | + return markCache; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export interface OgFact { | |
| 58 | + label: string; | |
| 59 | + value: string; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export interface OgProps { | |
| 63 | + /** Small upper label: entity kind or page family ("Cancer · NCIt C9005", "Gene", "Clinical trial"). */ | |
| 64 | + kicker: string; | |
| 65 | + title: string; | |
| 66 | + /** One line under the title (canonical name, mechanism, sponsor…). */ | |
| 67 | + subtitle?: string | null; | |
| 68 | + /** Up to four sourced figures shown as chips (label + value). */ | |
| 69 | + facts?: OgFact[]; | |
| 70 | + /** Footer right-hand note, e.g. the sources behind the facts. */ | |
| 71 | + sourcesNote?: string | null; | |
| 72 | +} | |
| 73 | + | |
| 74 | +function clampTitle(t: string): { text: string; size: number } { | |
| 75 | + const text = t.length > 110 ? `${t.slice(0, 107).trimEnd()}…` : t; | |
| 76 | + const size = text.length <= 28 ? 84 : text.length <= 48 ? 68 : text.length <= 72 ? 56 : 46; | |
| 77 | + return { text, size }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +export async function ogImage(props: OgProps): Promise<ImageResponse> { | |
| 81 | + const [fonts, mark] = await Promise.all([loadFonts(), loadMark()]); | |
| 82 | + const { text, size } = clampTitle(props.title); | |
| 83 | + const facts = (props.facts ?? []).slice(0, 4); | |
| 84 | + return new ImageResponse( | |
| 85 | + ( | |
| 86 | + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: PAPER, color: INK, fontFamily: 'Inter', padding: '56px 64px 48px' }}> | |
| 87 | + {/* top row: brand */} | |
| 88 | + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> | |
| 89 | + <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}> | |
| 90 | + <img src={mark} width={64} height={64} alt="" /> | |
| 91 | + <div style={{ display: 'flex', flexDirection: 'column' }}> | |
| 92 | + <div style={{ display: 'flex', fontFamily: 'Newsreader', fontWeight: 500, fontSize: 40, letterSpacing: -0.5, lineHeight: 1 }}> | |
| 93 | + <span>Cancer</span> | |
| 94 | + <span style={{ color: ACCENT }}>Index</span> | |
| 95 | + </div> | |
| 96 | + <div style={{ fontSize: 15, letterSpacing: 2.2, textTransform: 'uppercase', color: INK3, marginTop: 6 }}>The global index of cancer</div> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + <div style={{ fontSize: 18, color: INK3, textTransform: 'uppercase', letterSpacing: 2 }}>{props.kicker}</div> | |
| 100 | + </div> | |
| 101 | + | |
| 102 | + {/* title block */} | |
| 103 | + <div style={{ display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center', paddingTop: 24, paddingBottom: 16 }}> | |
| 104 | + <div style={{ fontFamily: 'Newsreader', fontWeight: 500, fontSize: size, lineHeight: 1.05, letterSpacing: -1, color: INK, maxWidth: 1072, display: 'flex' }}>{text}</div> | |
| 105 | + {props.subtitle ? <div style={{ marginTop: 18, fontSize: 26, color: INK2, lineHeight: 1.3, maxWidth: 1040, display: 'flex' }}>{props.subtitle.length > 140 ? `${props.subtitle.slice(0, 137).trimEnd()}…` : props.subtitle}</div> : null} | |
| 106 | + </div> | |
| 107 | + | |
| 108 | + {/* facts */} | |
| 109 | + {facts.length ? ( | |
| 110 | + <div style={{ display: 'flex', gap: 14, marginBottom: 26 }}> | |
| 111 | + {facts.map((f) => ( | |
| 112 | + <div key={f.label} style={{ display: 'flex', flexDirection: 'column', background: ACCENT_SOFT, border: `1px solid ${RULE}`, borderRadius: 4, padding: '12px 18px', minWidth: 160 }}> | |
| 113 | + <div style={{ fontSize: 30, fontWeight: 600, color: INK, lineHeight: 1.1 }}>{f.value}</div> | |
| 114 | + <div style={{ fontSize: 15, color: INK2, marginTop: 4, textTransform: 'uppercase', letterSpacing: 1.2 }}>{f.label}</div> | |
| 115 | + </div> | |
| 116 | + ))} | |
| 117 | + </div> | |
| 118 | + ) : null} | |
| 119 | + | |
| 120 | + {/* footer */} | |
| 121 | + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 32, borderTop: `2px solid ${RULE}`, paddingTop: 18, fontSize: 18, color: INK3 }}> | |
| 122 | + <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexShrink: 0 }}> | |
| 123 | + <div style={{ width: 10, height: 10, borderRadius: 5, background: ACCENT }} /> | |
| 124 | + <span>www.cancerindex.io · a source, a date and a formula version on every number</span> | |
| 125 | + </div> | |
| 126 | + {props.sourcesNote ? <span style={{ fontSize: 15, textAlign: 'right', maxWidth: 380, lineHeight: 1.25 }}>{props.sourcesNote.length > 90 ? `${props.sourcesNote.slice(0, 87).trimEnd()}…` : props.sourcesNote}</span> : null} | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + ), | |
| 130 | + { ...OG_SIZE, fonts }, | |
| 131 | + ); | |
| 132 | +} | |
| 133 | ||