Web core: Next 16 app (design system, shell, home, search, models, companies, entity pages, changes, explore, methodology, sources, developers, sitemap); provider slugs -api
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
81 changed files +8,142 −1
added
apps/web/.gitignore
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +.next/ | |
| 2 | +out/ | |
| 3 | +node_modules/ | |
| 4 | +next-env.d.ts | |
| 5 | +tsconfig.tsbuildinfo | |
| 6 | +qa/screens/ | |
| 7 | +.env*.local | |
added
apps/web/next.config.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +import type { NextConfig } from 'next'; | |
| 2 | +import { existsSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | + | |
| 5 | +// Monorepo: a single `.env` lives at the repository root; Next only reads the app directory. | |
| 6 | +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) { | |
| 7 | + if (existsSync(candidate)) { | |
| 8 | + try { | |
| 9 | + process.loadEnvFile(candidate); | |
| 10 | + } catch { | |
| 11 | + /* ignore malformed env */ | |
| 12 | + } | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +// Dev: FastAPI on :8331 (see repo .env). Production sets API_URL=http://127.0.0.1:8321 in the mld manifest. | |
| 17 | +const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, ''); | |
| 18 | + | |
| 19 | +const nextConfig: NextConfig = { | |
| 20 | + reactStrictMode: true, | |
| 21 | + poweredByHeader: false, | |
| 22 | + allowedDevOrigins: ['127.0.0.1', 'localhost'], | |
| 23 | + outputFileTracingRoot: path.resolve(__dirname, '../..'), | |
| 24 | + experimental: { | |
| 25 | + optimizePackageImports: ['lucide-react'], | |
| 26 | + }, | |
| 27 | + // Browser-side fetches go to the same origin; the FastAPI service is loopback-only. | |
| 28 | + async rewrites() { | |
| 29 | + return [ | |
| 30 | + { source: '/api/v1/:path*', destination: `${API_URL}/api/v1/:path*` }, | |
| 31 | + { source: '/health', destination: `${API_URL}/health` }, | |
| 32 | + ]; | |
| 33 | + }, | |
| 34 | + async headers() { | |
| 35 | + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=300, stale-while-revalidate=3600' }; | |
| 36 | + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' }; | |
| 37 | + return [ | |
| 38 | + { | |
| 39 | + source: '/(.*)', | |
| 40 | + headers: [ | |
| 41 | + { key: 'X-Content-Type-Options', value: 'nosniff' }, | |
| 42 | + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, | |
| 43 | + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, | |
| 44 | + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, | |
| 45 | + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, | |
| 46 | + ], | |
| 47 | + }, | |
| 48 | + { source: '/models/:path*', headers: [PUBLIC_CACHE] }, | |
| 49 | + { source: '/companies/:path*', headers: [PUBLIC_CACHE] }, | |
| 50 | + { source: '/papers/:path*', headers: [PUBLIC_CACHE] }, | |
| 51 | + { source: '/providers/:path*', headers: [PUBLIC_CACHE] }, | |
| 52 | + { source: '/benchmarks/:path*', headers: [PUBLIC_CACHE] }, | |
| 53 | + { source: '/hardware/:path*', headers: [PUBLIC_CACHE] }, | |
| 54 | + { source: '/api/:path*', headers: [NO_STORE] }, | |
| 55 | + { source: '/admin/:path*', headers: [NO_STORE] }, | |
| 56 | + ]; | |
| 57 | + }, | |
| 58 | +}; | |
| 59 | + | |
| 60 | +export default nextConfig; | |
added
apps/web/package.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@ai-atlas/web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "scripts": { | |
| 6 | + "dev": "next dev -p 8330", | |
| 7 | + "build": "next build", | |
| 8 | + "start": "next start -p 8320 -H 0.0.0.0", | |
| 9 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 10 | + "qa": "node qa/screens.mjs" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "d3-array": "^3.2.4", | |
| 14 | + "d3-scale": "^4.0.2", | |
| 15 | + "d3-shape": "^3.2.0", | |
| 16 | + "geist": "^1.5.1", | |
| 17 | + "lucide-react": "^1.0.0", | |
| 18 | + "next": "16.3.4", | |
| 19 | + "react": "19.2.8", | |
| 20 | + "react-dom": "19.2.8", | |
| 21 | + "server-only": "^0.0.1" | |
| 22 | + }, | |
| 23 | + "devDependencies": { | |
| 24 | + "@tailwindcss/postcss": "^4", | |
| 25 | + "@types/d3-array": "^3.2.1", | |
| 26 | + "@types/d3-scale": "^4.0.9", | |
| 27 | + "@types/d3-shape": "^3.1.7", | |
| 28 | + "@types/node": "^24.0.0", | |
| 29 | + "@types/react": "^19", | |
| 30 | + "@types/react-dom": "^19", | |
| 31 | + "tailwindcss": "^4", | |
| 32 | + "typescript": "^5.9.3" | |
| 33 | + } | |
| 34 | +} | |
added
apps/web/postcss.config.mjs
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +const config = { | |
| 2 | + plugins: { | |
| 3 | + '@tailwindcss/postcss': {}, | |
| 4 | + }, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +export default config; | |
added
apps/web/public/logo.svg
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> | |
| 2 | + <title>AI Atlas</title> | |
| 3 | + <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.6"/> | |
| 4 | + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> | |
| 5 | + <path d="M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> | |
| 6 | + <path d="M3 16h26" stroke="currentColor" stroke-width="1.2" opacity="0.7"/> | |
| 7 | + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="currentColor" stroke-width="1.2" opacity="0.55"/> | |
| 8 | + <circle cx="16" cy="16" r="2.4" fill="#1f4fd8"/> | |
| 9 | + <circle cx="9.4" cy="9.5" r="1.7" fill="currentColor"/> | |
| 10 | + <circle cx="22.6" cy="9.5" r="1.7" fill="currentColor"/> | |
| 11 | + <circle cx="10.8" cy="24.4" r="1.7" fill="currentColor"/> | |
| 12 | + <circle cx="23.4" cy="21.6" r="1.7" fill="currentColor"/> | |
| 13 | +</svg> | |
added
apps/web/qa/screens.mjs
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +/** | |
| 2 | + * QA sweep: key routes at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal overflow, screenshot. | |
| 3 | + * Also asserts that the homepage counters match GET /api/v1/stats. | |
| 4 | + * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8330, http://127.0.0.1:8331) | |
| 5 | + */ | |
| 6 | +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 7 | +import { mkdirSync } from 'node:fs'; | |
| 8 | + | |
| 9 | +const BASE = process.argv[2] ?? 'http://localhost:8330'; | |
| 10 | +const API = process.argv[3] ?? 'http://127.0.0.1:8331'; | |
| 11 | +const OUT = new URL('./screens/', import.meta.url).pathname; | |
| 12 | +mkdirSync(OUT, { recursive: true }); | |
| 13 | + | |
| 14 | +const PAGES = ['/', '/search?q=claude', '/models', '/models?openness=open-weights&sort=params', '/models/claude-opus-5', '/companies', '/companies/anthropic', '/providers', '/providers/anthropic-2', '/benchmarks', '/benchmarks/tau-bench', '/hardware/nvidia-dgx-b200', '/hardware', '/papers', '/frameworks', '/datasets', '/tools', '/changes', `/changes/${new Date().toISOString().slice(0, 10)}`, '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/developers', '/bot', '/models/does-not-exist', '/does-not-exist']; | |
| 15 | +const WIDTHS = [390, 1440]; | |
| 16 | +const THEMES = ['dark', 'light']; | |
| 17 | +const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); | |
| 18 | + | |
| 19 | +const browser = await chromium.launch(); | |
| 20 | +let failures = 0; | |
| 21 | +for (const theme of THEMES) { | |
| 22 | + for (const width of WIDTHS) { | |
| 23 | + const mobile = width < 768; | |
| 24 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme }); | |
| 25 | + await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme); | |
| 26 | + const page = await ctx.newPage(); | |
| 27 | + for (const path of PAGES) { | |
| 28 | + if (theme === 'light' && !['/', '/models', '/models/claude-opus-5', '/changes', '/search?q=claude', '/companies/anthropic'].includes(path)) continue; | |
| 29 | + const errors = []; | |
| 30 | + const onErr = (e) => errors.push(String(e)); | |
| 31 | + const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); }; | |
| 32 | + page.on('pageerror', onErr); | |
| 33 | + page.on('console', onCon); | |
| 34 | + const t0 = Date.now(); | |
| 35 | + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` })); | |
| 36 | + await page.waitForTimeout(600); | |
| 37 | + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1); | |
| 38 | + const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null); | |
| 39 | + const expected = /does-not-exist/.test(path) ? 404 : 200; | |
| 40 | + const status = res.status(); | |
| 41 | + // Dynamic-route 404s use Next's client-rendered error shell; React DEV warns about the inline theme <script> there | |
| 42 | + // (verified absent in `next start`). Everything else must be clean. | |
| 43 | + const filtered = errors.filter((e) => !/favicon|Failed to load resource: the server responded with a status of 404/.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e))); | |
| 44 | + const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme; | |
| 45 | + if (!ok) failures++; | |
| 46 | + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 140) : ''}`); | |
| 47 | + await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined); | |
| 48 | + page.off('pageerror', onErr); | |
| 49 | + page.off('console', onCon); | |
| 50 | + } | |
| 51 | + await ctx.close(); | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +// Homepage counters must come from /api/v1/stats (never hardcoded). The homepage is ISR-cached for 60 s while the API may be | |
| 56 | +// ingesting, so a counter is accepted when it lies between (stats before − growth over 60 s) and (stats after the page load): | |
| 57 | +// the tolerance is derived from the observed ingestion rate and is zero when the database is static. | |
| 58 | +try { | |
| 59 | + const pick = (s) => ({ models: s.entities?.model, 'change events': s.change_events, sources: s.sources, papers: s.entities?.paper, providers: s.entities?.provider, benchmarks: s.entities?.benchmark }); | |
| 60 | + const t0 = Date.now(); | |
| 61 | + const before = pick(await (await fetch(`${API}/api/v1/stats`)).json()); | |
| 62 | + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); | |
| 63 | + const page = await ctx.newPage(); | |
| 64 | + await page.goto(BASE + '/', { waitUntil: 'networkidle' }); | |
| 65 | + const text = await page.evaluate(() => document.body.innerText); | |
| 66 | + await ctx.close(); | |
| 67 | + const after = pick(await (await fetch(`${API}/api/v1/stats`)).json()); | |
| 68 | + const elapsed = Math.max(1, (Date.now() - t0) / 1000); | |
| 69 | + const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, ''))); | |
| 70 | + for (const label of Object.keys(before)) { | |
| 71 | + const a = Number(before[label]); | |
| 72 | + const b = Number(after[label]); | |
| 73 | + if (!Number.isFinite(a) || !Number.isFinite(b)) continue; | |
| 74 | + const rate = Math.max(0, b - a) / elapsed; | |
| 75 | + const lo = a - Math.ceil(rate * 60); | |
| 76 | + const hit = nums.some((n) => n >= lo && n <= b); | |
| 77 | + if (!hit) failures++; | |
| 78 | + console.log(`${hit ? 'OK ' : 'FAIL'} counter ${label}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(b)}] (stats ${fmt0.format(a)} → ${fmt0.format(b)} in ${elapsed.toFixed(0)} s)${hit ? '' : ' — NOT found'}`); | |
| 79 | + } | |
| 80 | +} catch (e) { | |
| 81 | + console.log(`SKIP counters check: API unreachable (${e.message})`); | |
| 82 | +} | |
| 83 | + | |
| 84 | +await browser.close(); | |
| 85 | +console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK'); | |
| 86 | +process.exit(failures ? 1 : 0); | |
added
apps/web/src/app/[type]/[slug]/page.tsx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { entityMetadata, loadEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { PATH_TYPES, routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. | |
| 10 | + * (/models and /companies have their own segment folders; static segments win over this dynamic one.) | |
| 11 | + */ | |
| 12 | +type Params = { params: Promise<{ type: string; slug: string }> }; | |
| 13 | + | |
| 14 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 15 | + const { type, slug } = await params; | |
| 16 | + if (!PATH_TYPES[type]) return { title: 'Not found', robots: { index: false } }; | |
| 17 | + return entityMetadata(type, slug); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export default async function GenericEntityPage({ params }: Params) { | |
| 21 | + const { type, slug } = await params; | |
| 22 | + if (!PATH_TYPES[type]) notFound(); | |
| 23 | + const d = await loadEntity(type, slug); | |
| 24 | + const canonical = routes.entity(d); | |
| 25 | + if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); | |
| 26 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 27 | + return <EntityPage d={d} canonical={canonical} related={related?.items} />; | |
| 28 | +} | |
added
apps/web/src/app/about/page.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 4 | +import { api, safe } from '@/lib/api'; | |
| 5 | +import { fmtBytes, fmtDate, fmtInt } from '@/lib/format'; | |
| 6 | +import { CONTACT_EMAIL, routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: 'About AI Atlas', description: 'Why AI Atlas exists: a structured, searchable, continuously updated map of the AI ecosystem with provenance on every fact and history that is never discarded.', alternates: { canonical: '/about' } }; | |
| 9 | +export const revalidate = 600; | |
| 10 | + | |
| 11 | +export default async function AboutPage() { | |
| 12 | + const stats = await safe(api.stats()); | |
| 13 | + return ( | |
| 14 | + <Container> | |
| 15 | + <PageHeader eyebrow="About" title="A map of the AI ecosystem you can audit" lede="AI Atlas is the most comprehensive, structured and continuously updated map of artificial intelligence: models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — connected in one graph, with the source of every fact." /> | |
| 16 | + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 17 | + <p>Think Bloomberg for AI: dense, current, sourced. Think Wikipedia: public, cross-linked, neutral. Think Crunchbase, Hugging Face and Papers With Code: organizations, models and research in one place. AI Atlas is none of these products — it is a dataset with a website and an API as its interfaces.</p> | |
| 18 | + <p>Three commitments shape everything. <strong className="text-ink">Provenance</strong>: every value shows where it came from, the tier of that source, and when it was last observed. <strong className="text-ink">History</strong>: prices, results and claims are append-only; you can ask what the atlas knew on a given date. <strong className="text-ink">Honesty</strong>: missing data reads “Unavailable”, conflicting sources are shown side by side, and the only derived numbers (hardware fit) are labelled as estimates.</p> | |
| 19 | + <p>Public information stays public: no login is needed to read anything on this site. Everything is also available through the <Link href={routes.developers()} className="link">public API</Link>.</p> | |
| 20 | + </div> | |
| 21 | + {stats && ( | |
| 22 | + <Section eyebrow="Right now" title="The atlas in numbers" hairline={false}> | |
| 23 | + <StatGrid cols={5}> | |
| 24 | + <Stat label="Entities" value={fmtInt(stats.entities_total)} /> | |
| 25 | + <Stat label="Claims" value={fmtInt(stats.claims)} hint={`${fmtInt(stats.claims_current)} current`} /> | |
| 26 | + <Stat label="Relations" value={fmtInt(stats.relations)} /> | |
| 27 | + <Stat label="Snapshots archived" value={fmtInt(stats.snapshots)} hint={fmtBytes(stats.archive?.raw_bytes)} /> | |
| 28 | + <Stat label="Since" value={fmtDate(stats.first_entity_at)} /> | |
| 29 | + </StatGrid> | |
| 30 | + </Section> | |
| 31 | + )} | |
| 32 | + <Section eyebrow="Who" title="Built and operated by Simon-Pierre Boucher"> | |
| 33 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 34 | + AI Atlas runs on <a href="https://www.maclustr.io" className="link" rel="noopener noreferrer">MacLustr</a>, a private Apple-silicon cluster in Québec that also hosts the local LLM factory used for extraction. Questions, corrections and source suggestions: <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>. Read the <Link href={routes.methodology()} className="link">methodology</Link>, browse the <Link href={routes.sources()} className="link">sources</Link>, or learn about the <Link href={routes.bot()} className="link">crawler</Link>. | |
| 35 | + </p> | |
| 36 | + </Section> | |
| 37 | + </Container> | |
| 38 | + ); | |
| 39 | +} | |
added
apps/web/src/app/apple-icon.tsx
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | + | |
| 3 | +/** 180×180 PNG generated from the AI Atlas mark (globe + relation graph) on the dark canvas. */ | |
| 4 | +export const size = { width: 180, height: 180 }; | |
| 5 | +export const contentType = 'image/png'; | |
| 6 | + | |
| 7 | +export default function AppleIcon() { | |
| 8 | + return new ImageResponse( | |
| 9 | + ( | |
| 10 | + <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11', borderRadius: 40 }}> | |
| 11 | + <svg width="132" height="132" viewBox="0 0 32 32" fill="none"> | |
| 12 | + <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" /> | |
| 13 | + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" /> | |
| 14 | + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" /> | |
| 15 | + <circle cx="16" cy="16" r="2.4" fill="#6d95ff" /> | |
| 16 | + <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" /> | |
| 17 | + <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" /> | |
| 18 | + <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" /> | |
| 19 | + <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" /> | |
| 20 | + </svg> | |
| 21 | + </div> | |
| 22 | + ), | |
| 23 | + { ...size }, | |
| 24 | + ); | |
| 25 | +} | |
added
apps/web/src/app/benchmarks/page.tsx
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 3 | +import { EntityLink } from '@/components/ui/entity'; | |
| 4 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 5 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtInt, fmtScore, num } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } }; | |
| 10 | +export const revalidate = 300; | |
| 11 | + | |
| 12 | +export default async function BenchmarksPage() { | |
| 13 | + const res = await safe(api.benchmarks()); | |
| 14 | + const items = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0)); | |
| 15 | + return ( | |
| 16 | + <Container> | |
| 17 | + <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} benchmarks</p> : undefined} /> | |
| 18 | + <div className="pb-16"> | |
| 19 | + {!res ? ( | |
| 20 | + <Unavailable what="Benchmarks" /> | |
| 21 | + ) : ( | |
| 22 | + <> | |
| 23 | + <DataTable caption="Benchmarks"> | |
| 24 | + <thead> | |
| 25 | + <tr> | |
| 26 | + <Th>Benchmark</Th> | |
| 27 | + <Th>Category</Th> | |
| 28 | + <Th>Metric</Th> | |
| 29 | + <Th num>Results</Th> | |
| 30 | + <Th num>Models</Th> | |
| 31 | + <Th>Current leader</Th> | |
| 32 | + </tr> | |
| 33 | + </thead> | |
| 34 | + <tbody> | |
| 35 | + {items.length === 0 && <EmptyRow cols={6}>No benchmarks recorded yet.</EmptyRow>} | |
| 36 | + {items.map((b) => { | |
| 37 | + const a = b.attributes ?? {}; | |
| 38 | + return ( | |
| 39 | + <tr key={b.id}> | |
| 40 | + <Td primary> | |
| 41 | + <EntityLink e={b} /> | |
| 42 | + {b.description && <span className="block max-w-md truncate text-xs text-ink-3">{b.description}</span>} | |
| 43 | + </Td> | |
| 44 | + <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? a.category : <span className="text-ink-3">—</span>}</Td> | |
| 45 | + <Td label="Metric" className="text-ink-2">{typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}</Td> | |
| 46 | + <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td> | |
| 47 | + <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td> | |
| 48 | + <Td label="Current leader"> | |
| 49 | + {b.top ? ( | |
| 50 | + <span> | |
| 51 | + <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}</span> | |
| 52 | + </span> | |
| 53 | + ) : ( | |
| 54 | + <span className="text-ink-3">—</span> | |
| 55 | + )} | |
| 56 | + </Td> | |
| 57 | + </tr> | |
| 58 | + ); | |
| 59 | + })} | |
| 60 | + </tbody> | |
| 61 | + </DataTable> | |
| 62 | + <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open the benchmark to see them.</Note> | |
| 63 | + </> | |
| 64 | + )} | |
| 65 | + </div> | |
| 66 | + </Container> | |
| 67 | + ); | |
| 68 | +} | |
added
apps/web/src/app/bot/page.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container, PageHeader, Section } from '@/components/ui/section'; | |
| 4 | +import { BOT_UA, CONTACT_EMAIL, routes } from '@/lib/site'; | |
| 5 | + | |
| 6 | +export const metadata: Metadata = { title: 'AIAtlasBot — crawler policy', description: 'About AIAtlasBot, the crawler that reads public AI documentation for AI Atlas: purpose, robots.txt compliance, rate limits and how to contact us or opt out.', alternates: { canonical: '/bot' } }; | |
| 7 | + | |
| 8 | +export default function BotPage() { | |
| 9 | + return ( | |
| 10 | + <Container> | |
| 11 | + <PageHeader eyebrow="Crawler" title={<span className="mono">{BOT_UA}</span>} lede="The crawler behind AI Atlas. It reads public documentation, pricing pages, model cards, papers, feeds and repositories to keep the atlas current and sourced." /> | |
| 12 | + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 13 | + <p> | |
| 14 | + User-Agent: <span className="mono text-ink">{BOT_UA}/0.1 (+https://www.ai-atlas.co/bot; {CONTACT_EMAIL})</span> | |
| 15 | + </p> | |
| 16 | + </div> | |
| 17 | + <Section eyebrow="Purpose" title="What it collects, and why" hairline={false}> | |
| 18 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">AIAtlasBot fetches publicly accessible pages that describe AI models, organizations, providers, prices, benchmarks, hardware, frameworks and datasets. Pages are archived and turned into attributed facts; each fact on AI Atlas links back to the page it came from, giving publishers credit and traffic. It does not collect personal data, does not log in, and does not fetch content behind paywalls or access controls.</p> | |
| 19 | + </Section> | |
| 20 | + <Section eyebrow="Behaviour" title="How it behaves"> | |
| 21 | + <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> | |
| 22 | + <li>Honours <span className="mono">robots.txt</span> (including <span className="mono">Crawl-delay</span>) for <span className="mono">{BOT_UA}</span> and <span className="mono">*</span>.</li> | |
| 23 | + <li>Per-domain rate limits (typically a few requests per minute; arXiv ≤ 4/min, Hugging Face ≤ 30/min, GitHub ≤ 20/min).</li> | |
| 24 | + <li>Conditional requests (<span className="mono">ETag</span> / <span className="mono">If-Modified-Since</span>) and content hashing so unchanged pages cost nothing.</li> | |
| 25 | + <li>Adaptive intervals: pages that rarely change are visited less often; failures back off with a circuit breaker.</li> | |
| 26 | + <li>Never bypasses CAPTCHAs or bot walls. If a site blocks us, the document is marked blocked and left alone.</li> | |
| 27 | + </ul> | |
| 28 | + </Section> | |
| 29 | + <Section eyebrow="Opt out" title="Blocking or contacting us"> | |
| 30 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">To exclude your site, add to your robots.txt:</p> | |
| 31 | + <pre className="mt-3 max-w-3xl border border-rule bg-surface p-4 text-[13px] text-ink"><code>{`User-agent: ${BOT_UA}\nDisallow: /`}</code></pre> | |
| 32 | + <p className="mt-3 max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 33 | + Changes are picked up on the next visit. For questions, corrections or removal requests write to <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>. The full list of sources is public on <Link href={routes.sources()} className="link">/sources</Link>. | |
| 34 | + </p> | |
| 35 | + </Section> | |
| 36 | + </Container> | |
| 37 | + ); | |
| 38 | +} | |
added
apps/web/src/app/changes/[date]/page.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { EntityLink } from '@/components/ui/entity'; | |
| 6 | +import { Container, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 7 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 8 | +import { api, safe } from '@/lib/api'; | |
| 9 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 10 | +import { categoryLabel, routes, SITE_NAME } from '@/lib/site'; | |
| 11 | + | |
| 12 | +type Params = { params: Promise<{ date: string }> }; | |
| 13 | +const VALID = /^\d{4}-\d{2}-\d{2}$/; | |
| 14 | + | |
| 15 | +function shift(date: string, days: number): string { | |
| 16 | + const d = new Date(`${date}T00:00:00Z`); | |
| 17 | + d.setUTCDate(d.getUTCDate() + days); | |
| 18 | + return d.toISOString().slice(0, 10); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 22 | + const { date } = await params; | |
| 23 | + if (!VALID.test(date)) return { title: 'Not found', robots: { index: false } }; | |
| 24 | + const title = `What changed in AI on ${fmtDate(date)}`; | |
| 25 | + return { title, description: `Daily digest of AI ecosystem changes for ${fmtDate(date)}: new models, prices, benchmarks, deprecations and announcements — generated from the ${SITE_NAME} database.`, alternates: { canonical: routes.changesDay(date) } }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export default async function DailyPage({ params }: Params) { | |
| 29 | + const { date } = await params; | |
| 30 | + if (!VALID.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) notFound(); | |
| 31 | + const today = new Date().toISOString().slice(0, 10); | |
| 32 | + if (date > today) notFound(); | |
| 33 | + const d = await safe(api.changesDaily(date)); | |
| 34 | + const total = d ? Object.values(d.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0) : 0; | |
| 35 | + const cats = d ? Object.entries(d.counts ?? {}).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0)) : []; | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <Container> | |
| 39 | + <PageHeader | |
| 40 | + eyebrow={<><Link href={routes.changes()} className="hover:text-ink">Changes</Link><span aria-hidden>/</span><span>Daily digest</span></>} | |
| 41 | + title={<>What changed in AI on <span className="text-ink-2">{fmtDate(date)}</span></>} | |
| 42 | + lede={d ? `${fmtInt(total)} events across ${cats.length} categories, generated from the database — nothing editorial.` : undefined} | |
| 43 | + aside={ | |
| 44 | + <nav className="flex items-center gap-2 text-sm" aria-label="Day navigation"> | |
| 45 | + <Link href={routes.changesDay(shift(date, -1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="prev">‹ {fmtDate(shift(date, -1))}</Link> | |
| 46 | + {date < today && <Link href={routes.changesDay(shift(date, 1))} className="inline-flex h-10 items-center border border-rule px-3 text-ink-2 hover:text-ink" rel="next">{fmtDate(shift(date, 1))} ›</Link>} | |
| 47 | + </nav> | |
| 48 | + } | |
| 49 | + /> | |
| 50 | + <div className="pb-16"> | |
| 51 | + {!d ? ( | |
| 52 | + <Unavailable what="Daily digest" /> | |
| 53 | + ) : total === 0 && !d.new_models?.length ? ( | |
| 54 | + <EmptyState title="Nothing recorded on this day">Connectors may not have observed changes, or the day predates the archive.</EmptyState> | |
| 55 | + ) : ( | |
| 56 | + <> | |
| 57 | + {cats.length > 0 && ( | |
| 58 | + <StatGrid cols={cats.length >= 6 ? 6 : cats.length >= 4 ? 4 : 3}> | |
| 59 | + {cats.slice(0, 6).map(([c, n]) => ( | |
| 60 | + <Stat key={c} label={categoryLabel(c)} value={fmtInt(n)} href={`/changes?category=${encodeURIComponent(c)}&since=${date}`} /> | |
| 61 | + ))} | |
| 62 | + </StatGrid> | |
| 63 | + )} | |
| 64 | + {d.new_models?.length > 0 && ( | |
| 65 | + <Section eyebrow="New models" title={<>{fmtInt(d.new_models.length)} new model{d.new_models.length === 1 ? '' : 's'}</>} hairline={false}> | |
| 66 | + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 67 | + {d.new_models.map((m) => ( | |
| 68 | + <li key={m.id} className="flex items-baseline justify-between gap-3 border-b border-rule py-2 text-sm"> | |
| 69 | + <EntityLink e={m} className="font-medium" /> | |
| 70 | + <span className="shrink-0 text-xs text-ink-3">{m.organization?.name ?? ''}</span> | |
| 71 | + </li> | |
| 72 | + ))} | |
| 73 | + </ul> | |
| 74 | + </Section> | |
| 75 | + )} | |
| 76 | + {d.sections | |
| 77 | + .filter((s) => s.items.length) | |
| 78 | + .map((s) => ( | |
| 79 | + <Section key={s.category} eyebrow={categoryLabel(s.category)} title={s.label} action={{ href: `/changes?category=${encodeURIComponent(s.category)}`, label: 'All' }}> | |
| 80 | + <ul className="border-t border-rule"> | |
| 81 | + {s.items.map((e) => ( | |
| 82 | + <ChangeRow key={e.id} e={e} live={false} /> | |
| 83 | + ))} | |
| 84 | + </ul> | |
| 85 | + </Section> | |
| 86 | + ))} | |
| 87 | + </> | |
| 88 | + )} | |
| 89 | + </div> | |
| 90 | + </Container> | |
| 91 | + ); | |
| 92 | +} | |
added
apps/web/src/app/changes/page.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ChangeRow, groupByDay } from '@/components/changes/change-row'; | |
| 4 | +import { LoadMore } from '@/components/changes/load-more'; | |
| 5 | +import { ActiveFilters, FilterBar } from '@/components/listing/filters'; | |
| 6 | +import { withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 11 | +import { categoryLabel, eventLabel, IMPORTANCE_LABELS, routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { | |
| 14 | + title: 'Changes — what changed in AI, as it happens', | |
| 15 | + description: 'A live, source-attributed feed of changes in the AI ecosystem: new models, price moves, context changes, deprecations, benchmark results, announcements.', | |
| 16 | + alternates: { canonical: '/changes' }, | |
| 17 | +}; | |
| 18 | +export const revalidate = 60; | |
| 19 | + | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | +const KEYS = ['category', 'type', 'entity_type', 'importance_min', 'since', 'until', 'q'] as const; | |
| 22 | +const LIMIT = 50; | |
| 23 | + | |
| 24 | +export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 25 | + const sp = await searchParams; | |
| 26 | + const current: Record<string, string | undefined> = {}; | |
| 27 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 28 | + const [page, cats] = await Promise.all([safe(api.changes({ ...current, limit: LIMIT })), safe(api.changesCategories(7))]); | |
| 29 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/changes', current, patch); | |
| 30 | + const qs = new URLSearchParams(Object.entries(current).filter(([, v]) => v) as [string, string][]).toString(); | |
| 31 | + const groups = groupByDay(page?.items ?? []); | |
| 32 | + const last = page?.items[page.items.length - 1]; | |
| 33 | + const cursor = page && page.items.length >= LIMIT && last ? last.observed_at : null; | |
| 34 | + const catCounts = new Map<string, number>(); | |
| 35 | + const typeCounts = new Map<string, number>(); | |
| 36 | + for (const c of cats?.items ?? []) { | |
| 37 | + catCounts.set(c.category, (catCounts.get(c.category) ?? 0) + (num(c.count) ?? 0)); | |
| 38 | + typeCounts.set(c.event_type, (typeCounts.get(c.event_type) ?? 0) + (num(c.count) ?? 0)); | |
| 39 | + } | |
| 40 | + const catOptions = [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${categoryLabel(value)} (${fmtInt(n)})` })); | |
| 41 | + const typeOptions = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, label: `${eventLabel(value)} (${fmtInt(n)})` })); | |
| 42 | + const today = new Date().toISOString().slice(0, 10); | |
| 43 | + | |
| 44 | + return ( | |
| 45 | + <Container> | |
| 46 | + <PageHeader eyebrow="Changes" title="What changed in AI" lede="Every material change the connectors observe becomes an event with a source. Filter by category, event type and importance; older events load with a cursor." aside={<Link href={routes.changesDay(today)} className="link text-sm">Today's digest →</Link>}> | |
| 47 | + <FilterBar | |
| 48 | + action="/changes" | |
| 49 | + className="mt-6" | |
| 50 | + resetHref={routes.changes()} | |
| 51 | + fields={[ | |
| 52 | + { kind: 'select', name: 'category', label: 'Category', value: current.category, options: catOptions }, | |
| 53 | + { kind: 'select', name: 'type', label: 'Event type', value: current.type, options: typeOptions }, | |
| 54 | + { kind: 'select', name: 'importance_min', label: 'Min importance', value: current.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) }, | |
| 55 | + { kind: 'text', name: 'since', label: 'Since', value: current.since, placeholder: 'YYYY-MM-DD' }, | |
| 56 | + { kind: 'text', name: 'q', label: 'Text', value: current.q, placeholder: 'in summary' }, | |
| 57 | + ]} | |
| 58 | + /> | |
| 59 | + <ActiveFilters current={current} labels={{ category: 'category', type: 'type', importance_min: 'importance ≥', since: 'since', until: 'until', q: 'text', entity_type: 'entity' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 60 | + {cats && catOptions.length > 0 && ( | |
| 61 | + <p className="mt-4 text-xs text-ink-3"> | |
| 62 | + Last 7 days: {[...catCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([c, n], i) => ( | |
| 63 | + <span key={c}> | |
| 64 | + {i > 0 && ' · '} | |
| 65 | + <Link href={href({ category: c })} className="hover:text-ink">{categoryLabel(c)} <span className="tnum text-ink-2">{fmtInt(n)}</span></Link> | |
| 66 | + </span> | |
| 67 | + ))} | |
| 68 | + </p> | |
| 69 | + )} | |
| 70 | + </PageHeader> | |
| 71 | + | |
| 72 | + <div className="pb-16"> | |
| 73 | + {!page ? ( | |
| 74 | + <Unavailable what="Change feed" /> | |
| 75 | + ) : page.items.length === 0 ? ( | |
| 76 | + <EmptyState title="No events match these filters">The change engine only emits events when a source states a material change — try widening the filters.</EmptyState> | |
| 77 | + ) : ( | |
| 78 | + <> | |
| 79 | + <p className="tnum text-xs text-ink-3">{fmtInt(page.total)} events{current.since ? ` since ${fmtDate(current.since)}` : ''}</p> | |
| 80 | + {groups.map((g) => ( | |
| 81 | + <section key={g.day} className="mt-6"> | |
| 82 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 83 | + <Link href={routes.changesDay(g.day)} className="hover:text-ink">{fmtDate(g.day)}</Link> <span className="tnum text-ink-3">{g.items.length}</span> | |
| 84 | + </h2> | |
| 85 | + <ul className="border-t border-rule"> | |
| 86 | + {g.items.map((e) => ( | |
| 87 | + <ChangeRow key={e.id} e={e} /> | |
| 88 | + ))} | |
| 89 | + </ul> | |
| 90 | + </section> | |
| 91 | + ))} | |
| 92 | + <LoadMore qs={qs} initialCursor={cursor} lastDay={groups[groups.length - 1]?.day ?? null} /> | |
| 93 | + </> | |
| 94 | + )} | |
| 95 | + </div> | |
| 96 | + </Container> | |
| 97 | + ); | |
| 98 | +} | |
added
apps/web/src/app/companies/[slug]/page.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { entityMetadata, loadEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +type Params = { params: Promise<{ slug: string }> }; | |
| 9 | + | |
| 10 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 11 | + const { slug } = await params; | |
| 12 | + return entityMetadata('companies', slug); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export default async function CompanyPage({ params }: Params) { | |
| 16 | + const { slug } = await params; | |
| 17 | + const d = await loadEntity('companies', slug); | |
| 18 | + if (d.slug !== slug) permanentRedirect(routes.entity(d)); | |
| 19 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 20 | + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; | |
| 21 | +} | |
added
apps/web/src/app/companies/page.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtInt, num, titleCase } from '@/lib/format'; | |
| 11 | +import { routes } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { | |
| 14 | + title: 'AI companies, labs and organizations', | |
| 15 | + description: 'Companies, research labs and organizations building AI: country, kind, models and papers in the atlas, with sources for every attribute.', | |
| 16 | + alternates: { canonical: '/companies' }, | |
| 17 | +}; | |
| 18 | +export const revalidate = 300; | |
| 19 | + | |
| 20 | +type SP = Record<string, string | undefined>; | |
| 21 | +const LIMIT = 50; | |
| 22 | +const KEYS = ['q', 'country', 'kind', 'sort', 'order', 'offset'] as const; | |
| 23 | +const SORTS = [ | |
| 24 | + { value: 'models', label: 'Most models' }, | |
| 25 | + { value: 'name', label: 'Name' }, | |
| 26 | + { value: 'updated', label: 'Recently updated' }, | |
| 27 | + { value: 'quality', label: 'Data quality' }, | |
| 28 | +]; | |
| 29 | + | |
| 30 | +export default async function CompaniesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 31 | + const sp = await searchParams; | |
| 32 | + const current: Record<string, string | undefined> = {}; | |
| 33 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 34 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 35 | + const sort = current.sort ?? 'models'; | |
| 36 | + const page = await safe(api.companies({ ...current, sort, limit: LIMIT, offset, facets: 1 })); | |
| 37 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/companies', current, patch); | |
| 38 | + const facetGroups: FacetGroup[] = [ | |
| 39 | + { key: 'country', label: 'Country', items: (page?.facets?.countries ?? []).map((x) => ({ value: x.value, count: x.count })) }, | |
| 40 | + { key: 'kind', label: 'Kind', items: (page?.facets?.kinds ?? []).map((x) => ({ value: x.value, label: titleCase(x.value), count: x.count })) }, | |
| 41 | + ]; | |
| 42 | + const sortHref = (s: string) => href({ sort: s, order: sort === s && current.order !== 'asc' ? 'asc' : undefined, offset: undefined }); | |
| 43 | + const SortTh = ({ s, children, num: n }: { s: string; children: React.ReactNode; num?: boolean }) => ( | |
| 44 | + <Th num={n} aria-sort={sort === s ? (current.order === 'asc' ? 'ascending' : 'descending') : undefined}> | |
| 45 | + <Link href={sortHref(s)} className={sort === s ? 'text-ink' : 'hover:text-ink'}> | |
| 46 | + {children} | |
| 47 | + {sort === s && <span aria-hidden> {current.order === 'asc' ? '↑' : '↓'}</span>} | |
| 48 | + </Link> | |
| 49 | + </Th> | |
| 50 | + ); | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <Container wide> | |
| 54 | + <PageHeader eyebrow="Companies" title="Companies, labs & organizations" lede="Who builds, serves and studies AI. Model and paper counts are live from the graph." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} organizations</p> : undefined}> | |
| 55 | + <FilterBar | |
| 56 | + action="/companies" | |
| 57 | + className="mt-6" | |
| 58 | + resetHref={routes.companies()} | |
| 59 | + fields={[ | |
| 60 | + { kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'e.g. Anthropic' }, | |
| 61 | + { kind: 'text', name: 'country', label: 'Country (ISO-2)', value: current.country, placeholder: 'US, FR, CN…' }, | |
| 62 | + ...(current.kind ? [{ kind: 'hidden' as const, name: 'kind', value: current.kind }] : []), | |
| 63 | + ]} | |
| 64 | + sort={{ value: sort, options: SORTS }} | |
| 65 | + /> | |
| 66 | + <ActiveFilters current={current} labels={{ q: 'query', country: 'country', kind: 'kind' }} makeHref={(p) => href(p)} className="mt-3" /> | |
| 67 | + </PageHeader> | |
| 68 | + <div className="pb-16"> | |
| 69 | + <ListingLayout facets={<Facets groups={facetGroups} current={current} makeHref={(p) => href(p)} />}> | |
| 70 | + {!page ? ( | |
| 71 | + <Unavailable what="Companies" /> | |
| 72 | + ) : ( | |
| 73 | + <> | |
| 74 | + <DataTable caption="Companies"> | |
| 75 | + <thead> | |
| 76 | + <tr> | |
| 77 | + <SortTh s="name">Organization</SortTh> | |
| 78 | + <Th>Country</Th> | |
| 79 | + <Th>Kind</Th> | |
| 80 | + <Th>Founded</Th> | |
| 81 | + <SortTh s="models" num>Models</SortTh> | |
| 82 | + <Th num>Papers</Th> | |
| 83 | + <SortTh s="quality" num>Quality</SortTh> | |
| 84 | + </tr> | |
| 85 | + </thead> | |
| 86 | + <tbody> | |
| 87 | + {page.items.length === 0 && <EmptyRow cols={7}>No organizations match these filters.</EmptyRow>} | |
| 88 | + {page.items.map((c) => { | |
| 89 | + const a = c.attributes ?? {}; | |
| 90 | + return ( | |
| 91 | + <tr key={c.id}> | |
| 92 | + <Td primary> | |
| 93 | + <EntityLink e={c} /> | |
| 94 | + {c.description && <span className="block max-w-md truncate text-xs text-ink-3">{c.description}</span>} | |
| 95 | + </Td> | |
| 96 | + <Td label="Country" className="mono text-ink-2">{typeof a.country === 'string' ? a.country : <span className="text-ink-3">—</span>}</Td> | |
| 97 | + <Td label="Kind" className="text-ink-2">{typeof a.org_kind === 'string' ? titleCase(a.org_kind) : <span className="text-ink-3">—</span>}</Td> | |
| 98 | + <Td label="Founded" className="tnum text-ink-2">{a.founded ? String(a.founded).slice(0, 4) : <span className="text-ink-3">—</span>}</Td> | |
| 99 | + <Td num label="Models" className="tnum">{num(c.model_count) ? <Link href={`/models?org=${encodeURIComponent(c.slug)}`} className="hover:text-accent">{fmtInt(c.model_count)}</Link> : <span className="text-ink-3">0</span>}</Td> | |
| 100 | + <Td num label="Papers" className="tnum">{num(c.paper_count) ? fmtInt(c.paper_count) : <span className="text-ink-3">0</span>}</Td> | |
| 101 | + <Td num label="Quality"><QualityMark q={c.quality?.score} /></Td> | |
| 102 | + </tr> | |
| 103 | + ); | |
| 104 | + })} | |
| 105 | + </tbody> | |
| 106 | + </DataTable> | |
| 107 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 108 | + </> | |
| 109 | + )} | |
| 110 | + </ListingLayout> | |
| 111 | + </div> | |
| 112 | + </Container> | |
| 113 | + ); | |
| 114 | +} | |
added
apps/web/src/app/compare/page.tsx
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 4 | +import { DataTable, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { EntityLink } from '@/components/ui/entity'; | |
| 6 | +import { ProvenanceInline } from '@/components/ui/provenance'; | |
| 7 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtDate, fmtValue, num } from '@/lib/format'; | |
| 11 | +import { routes, typeLabel } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical: '/compare' } }; | |
| 14 | +export const revalidate = 300; | |
| 15 | + | |
| 16 | +export default async function ComparePage({ searchParams }: { searchParams: Promise<{ ids?: string }> }) { | |
| 17 | + const { ids: raw } = await searchParams; | |
| 18 | + const ids = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6); | |
| 19 | + const res = ids.length >= 2 ? await safe(api.compare(ids)) : null; | |
| 20 | + return ( | |
| 21 | + <Container wide> | |
| 22 | + <PageHeader eyebrow="Compare" title={res ? `Comparing ${res.items.length} ${typeLabel(res.entity_type, true).toLowerCase()}` : 'Compare side by side'} lede="Two to six entities of one type. Each cell shows the recorded value and, on hover, where it came from."> | |
| 23 | + <form action="/compare" method="get" className="mt-6 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 24 | + <label htmlFor="ids" className="sr-only">Slugs, comma separated</label> | |
| 25 | + <input id="ids" name="ids" defaultValue={ids.join(',')} placeholder="slugs separated by commas, e.g. claude-opus-5,gpt-5,gemini-3-pro" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} /> | |
| 26 | + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Compare</button> | |
| 27 | + </form> | |
| 28 | + <Note className="mt-2">Find slugs in any entity URL, or use “Add to a comparison” on entity pages. Picker UI is coming.</Note> | |
| 29 | + </PageHeader> | |
| 30 | + <div className="pb-16"> | |
| 31 | + {ids.length < 2 ? ( | |
| 32 | + <EmptyState title="Enter at least two slugs"> | |
| 33 | + Example: <Link href={routes.compare(['claude-opus-5', 'gpt-5'])} className="link">claude-opus-5 vs gpt-5</Link> | |
| 34 | + </EmptyState> | |
| 35 | + ) : !res ? ( | |
| 36 | + <Unavailable what="Comparison" reason="One of the slugs may not exist, or the entities are of different types." /> | |
| 37 | + ) : ( | |
| 38 | + <> | |
| 39 | + <DataTable scroll stack={false} caption="Comparison"> | |
| 40 | + <thead> | |
| 41 | + <tr> | |
| 42 | + <Th>Dimension</Th> | |
| 43 | + {res.items.map((it) => ( | |
| 44 | + <Th key={it.entity.id}> | |
| 45 | + <span className="flex flex-col items-start gap-0.5 normal-case tracking-normal"> | |
| 46 | + <EntityBadge type={it.entity.entity_type} small /> | |
| 47 | + <EntityLink e={it.entity} className="text-sm font-semibold" /> | |
| 48 | + {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>} | |
| 49 | + </span> | |
| 50 | + </Th> | |
| 51 | + ))} | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + {res.dimensions.map((dim) => { | |
| 56 | + const nums = res.items.map((it) => num(it.values[dim.key])); | |
| 57 | + const best = dim.kind === 'number' && nums.some((n) => n !== null) ? Math.max(...(nums.filter((n): n is number => n !== null))) : null; | |
| 58 | + return ( | |
| 59 | + <tr key={dim.key}> | |
| 60 | + <Td className="text-ink-2"> | |
| 61 | + {dim.label} | |
| 62 | + {dim.unit && <span className="text-ink-3"> · {dim.unit}</span>} | |
| 63 | + </Td> | |
| 64 | + {res.items.map((it) => { | |
| 65 | + const v = it.values[dim.key]; | |
| 66 | + const p = it.provenance?.[dim.key]; | |
| 67 | + const n = num(v); | |
| 68 | + return ( | |
| 69 | + <Td key={it.entity.id} className={dim.kind === 'number' ? 'tnum' : undefined}> | |
| 70 | + <span className={best !== null && n === best && nums.filter((x) => x !== null).length > 1 ? 'font-semibold text-ink' : undefined} title={p ? `${p.source_name ?? p.url ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}> | |
| 71 | + {v === null || v === undefined || v === '' ? <span className="text-ink-3">Unavailable</span> : dim.kind === 'date' ? fmtDate(String(v)) : fmtValue(v, dim.key)} | |
| 72 | + </span> | |
| 73 | + {p && <ProvenanceInline p={p} showConfidence={false} className="hidden md:flex" />} | |
| 74 | + </Td> | |
| 75 | + ); | |
| 76 | + })} | |
| 77 | + </tr> | |
| 78 | + ); | |
| 79 | + })} | |
| 80 | + </tbody> | |
| 81 | + </DataTable> | |
| 82 | + <Note className="mt-3">Bold marks the highest number in a row; it is not a verdict. Benchmark rows only appear when all compared entities have a result under a comparable configuration.</Note> | |
| 83 | + </> | |
| 84 | + )} | |
| 85 | + </div> | |
| 86 | + </Container> | |
| 87 | + ); | |
| 88 | +} | |
added
apps/web/src/app/datasets/page.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | + | |
| 4 | +export const metadata: Metadata = { title: 'AI datasets', description: 'Training and evaluation datasets with modality, size, license and publisher, linked to the models trained on them.', alternates: { canonical: '/datasets' } }; | |
| 5 | +export const revalidate = 300; | |
| 6 | + | |
| 7 | +export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 8 | + const sp = await searchParams; | |
| 9 | + return <GenericListing type="dataset" basePath="/datasets" eyebrow="Datasets" title="Datasets" lede="Training and evaluation corpora as documented on Hugging Face and publisher pages." searchParams={sp} />; | |
| 10 | +} | |
added
apps/web/src/app/developers/page.tsx
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { DataTable, Td, Th } from '@/components/ui/data-table'; | |
| 4 | +import { Container, Note, PageHeader, Section } from '@/components/ui/section'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { PUBLIC_API_BASE, routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, examples with curl.', alternates: { canonical: '/developers' } }; | |
| 9 | +export const revalidate = 3600; | |
| 10 | + | |
| 11 | +const ENDPOINTS: { path: string; returns: string }[] = [ | |
| 12 | + { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' }, | |
| 13 | + { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size' }, | |
| 14 | + { path: 'GET /stats/history?days=90', returns: 'Daily counts' }, | |
| 15 | + { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search; returns the compiled filters and ranked entities' }, | |
| 16 | + { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions' }, | |
| 17 | + { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance, relations, sources, timeline and type-specific blocks (prices, results, lineage, hardware_fit, models, papers)' }, | |
| 18 | + { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' }, | |
| 19 | + { path: 'GET /entities/{slug}/timeline · /history?property= · /asof?date= · /graph?depth= · /sources · /related', returns: 'Events, full claim history, state as of a date, neighbourhood graph, documents, related entities' }, | |
| 20 | + { path: 'GET /models?q=&org=&openness=&modality=&status=&min_params=&min_context=&year_from=&license=&sort=&facets=1', returns: 'Paged models with facets' }, | |
| 21 | + { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model and paper counts' }, | |
| 22 | + { path: 'GET /papers?q=&category=&org=&since=&until=', returns: 'Paged papers' }, | |
| 23 | + { path: 'GET /providers · GET /prices?model=&provider=¤t=1 · GET /prices/history · GET /prices/index?days=', returns: 'Providers, current prices, full price history, median/min price index and movers' }, | |
| 24 | + { path: 'GET /benchmarks · GET /benchmarks/{slug}/results · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks, leaderboards (with config), result history' }, | |
| 25 | + { path: 'GET /hardware · GET /hardware/fit?memory_gb=&quant=&context=', returns: 'Hardware listing and the estimated fit tool' }, | |
| 26 | + { path: 'GET /explore/types · GET /explore/{type}', returns: 'Entity types with counts; generic listing for any type' }, | |
| 27 | + { path: 'GET /changes?category=&type=&importance_min=&since=&before= · GET /changes/daily?date= · GET /changes/categories', returns: 'Change-event feed (cursor paged), daily digest, category counts' }, | |
| 28 | + { path: 'GET /timeline?entity=&year=&category=', returns: 'Events grouped by month' }, | |
| 29 | + { path: 'GET /compare?ids=a,b,c', returns: 'Side-by-side dimensions with provenance (2–6 entities of one type)' }, | |
| 30 | + { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=', returns: 'What changed between two dates' }, | |
| 31 | + { path: 'GET /sources · GET /methodology · GET /trending · GET /sitemap', returns: 'Transparency, vocabularies, most viewed, sitemap feed' }, | |
| 32 | +]; | |
| 33 | + | |
| 34 | +export default async function DevelopersPage() { | |
| 35 | + const health = await safe(api.health()); | |
| 36 | + return ( | |
| 37 | + <Container> | |
| 38 | + <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} /> | |
| 39 | + | |
| 40 | + <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}> | |
| 41 | + <p className="text-sm text-ink-2"> | |
| 42 | + Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503. | |
| 43 | + </p> | |
| 44 | + <pre className="scrollbar-thin mt-4 overflow-x-auto border border-rule bg-surface p-4 text-[13px] leading-relaxed text-ink"><code>{`# Live counters | |
| 45 | +curl -s ${PUBLIC_API_BASE}/stats | jq '.entities' | |
| 46 | + | |
| 47 | +# Natural-language search → compiled filters + ranked results | |
| 48 | +curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]' | |
| 49 | + | |
| 50 | +# A model with provenance, prices, results, lineage | |
| 51 | +curl -s ${PUBLIC_API_BASE}/models/claude-opus-5 | jq '{name, attributes, provenance: (.provenance | keys)}' | |
| 52 | + | |
| 53 | +# Price history for one model across providers | |
| 54 | +curl -s "${PUBLIC_API_BASE}/prices/history?model=claude-opus-5" | jq '.items[] | {provider: .provider.name, input_per_mtok, valid_from, valid_to}' | |
| 55 | + | |
| 56 | +# What changed today | |
| 57 | +curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</code></pre> | |
| 58 | + </Section> | |
| 59 | + | |
| 60 | + <Section eyebrow="Endpoints" title="Public routes"> | |
| 61 | + <DataTable caption="Public endpoints" compact> | |
| 62 | + <thead><tr><Th>Route</Th><Th>Returns</Th></tr></thead> | |
| 63 | + <tbody> | |
| 64 | + {ENDPOINTS.map((e) => ( | |
| 65 | + <tr key={e.path}> | |
| 66 | + <Td primary className="mono text-[12.5px] break-words">{e.path}</Td> | |
| 67 | + <Td label="Returns" wide className="text-ink-2">{e.returns}</Td> | |
| 68 | + </tr> | |
| 69 | + ))} | |
| 70 | + </tbody> | |
| 71 | + </DataTable> | |
| 72 | + <Note className="mt-3">Shapes are documented in the OpenAPI schema. Postgres aggregates may arrive as strings; treat numeric fields as <span className="mono">number | string | null</span>.</Note> | |
| 73 | + </Section> | |
| 74 | + | |
| 75 | + <Section eyebrow="Terms" title="Fair use"> | |
| 76 | + <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2"> | |
| 77 | + <li>Public routes are free to use without a key. Please cache responses and identify your client with a User-Agent that includes a contact address.</li> | |
| 78 | + <li>Search is rate-limited per IP (HTTP 429 when exceeded). Higher limits and developer keys (<span className="mono">x-api-key</span>) will be available on request — see <Link href={routes.about()} className="link">contact</Link>.</li> | |
| 79 | + <li>Attribution: “Data: AI Atlas (www.ai-atlas.co)” with a link. Every record carries its own upstream sources; please keep them when you redistribute.</li> | |
| 80 | + <li>Never treat a missing field as zero. <span className="mono">null</span> means the sources did not state it.</li> | |
| 81 | + </ul> | |
| 82 | + </Section> | |
| 83 | + </Container> | |
| 84 | + ); | |
| 85 | +} | |
added
apps/web/src/app/error.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container } from '@/components/ui/section'; | |
| 4 | + | |
| 5 | +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { | |
| 6 | + return ( | |
| 7 | + <Container className="py-20 md:py-28"> | |
| 8 | + <p className="eyebrow">Error</p> | |
| 9 | + <h1 className="display mt-2 text-3xl md:text-5xl">Something went wrong.</h1> | |
| 10 | + <p className="mt-4 max-w-xl text-ink-2">The page could not be rendered. The dataset itself is fine — this is usually the API being briefly unavailable.</p> | |
| 11 | + {error.digest && <p className="mono mt-2 text-xs text-ink-3">ref {error.digest}</p>} | |
| 12 | + <div className="mt-6 flex gap-3 text-sm"> | |
| 13 | + <button type="button" onClick={reset} className="bg-ink px-3 py-2 text-canvas hover:opacity-90"> | |
| 14 | + Try again | |
| 15 | + </button> | |
| 16 | + <Link href="/" className="border border-rule px-3 py-2 text-ink-2 hover:text-ink"> | |
| 17 | + Home | |
| 18 | + </Link> | |
| 19 | + </div> | |
| 20 | + </Container> | |
| 21 | + ); | |
| 22 | +} | |
added
apps/web/src/app/explore/[type]/[slug]/page.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { buildMetadata, loadAnyEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { routes, TYPE_PATH } from '@/lib/site'; | |
| 7 | + | |
| 8 | +/** Fallback detail page for entity types without a dedicated path (regulation, incident, researcher, …). */ | |
| 9 | +type Params = { params: Promise<{ type: string; slug: string }> }; | |
| 10 | + | |
| 11 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 12 | + const { type, slug } = await params; | |
| 13 | + const d = await safe(api.entity(slug)); | |
| 14 | + if (!d || d.entity_type !== type) return { title: 'Not found', robots: { index: false } }; | |
| 15 | + return buildMetadata(d); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default async function ExploreEntityPage({ params }: Params) { | |
| 19 | + const { type, slug } = await params; | |
| 20 | + const d = await loadAnyEntity(type, slug); | |
| 21 | + if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d)); | |
| 22 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 23 | + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; | |
| 24 | +} | |
added
apps/web/src/app/explore/[type]/page.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 3 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 4 | +import { TYPE_LABELS, TYPE_PATH, typeLabel } from '@/lib/site'; | |
| 5 | + | |
| 6 | +type Params = { params: Promise<{ type: string }>; searchParams: Promise<Record<string, string | undefined>> }; | |
| 7 | + | |
| 8 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 9 | + const { type } = await params; | |
| 10 | + if (!/^[a-z_]+$/.test(type)) return { title: 'Not found', robots: { index: false } }; | |
| 11 | + return { title: `${typeLabel(type, true)} — explore`, description: `All ${typeLabel(type, true).toLowerCase()} in AI Atlas, with sources and history.`, alternates: { canonical: `/explore/${type}` } }; | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** Generic listing for any type. Types with a dedicated section redirect there (one canonical listing per type). */ | |
| 15 | +export default async function ExploreTypePage({ params, searchParams }: Params) { | |
| 16 | + const { type } = await params; | |
| 17 | + if (!/^[a-z_]+$/.test(type) || type.length > 32) notFound(); | |
| 18 | + if (TYPE_PATH[type]) permanentRedirect(`/${TYPE_PATH[type]}`); | |
| 19 | + if (!TYPE_LABELS[type]) notFound(); | |
| 20 | + const sp = await searchParams; | |
| 21 | + return <GenericListing type={type} basePath={`/explore/${type}`} eyebrow="Explore" title={typeLabel(type, true)} searchParams={sp} />; | |
| 22 | +} | |
added
apps/web/src/app/explore/page.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 4 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 5 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 6 | +import { api, safe } from '@/lib/api'; | |
| 7 | +import { fmtInt, num } from '@/lib/format'; | |
| 8 | +import { routes, typeLabel } from '@/lib/site'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: 'Explore — every entity type with live counts', description: 'Browse the AI Atlas graph by entity type: models, companies, papers, providers, benchmarks, hardware, frameworks, datasets, tools and more.', alternates: { canonical: '/explore' } }; | |
| 11 | +export const revalidate = 300; | |
| 12 | + | |
| 13 | +const BLURB: Record<string, string> = { | |
| 14 | + model: 'Foundation and fine-tuned models: parameters, context, openness, prices, benchmarks, lineage.', | |
| 15 | + company: 'Labs and companies that develop, serve or study AI.', | |
| 16 | + paper: 'Research papers linked to the models and benchmarks they describe.', | |
| 17 | + provider: 'Inference providers and their published prices per 1M tokens.', | |
| 18 | + benchmark: 'Evaluation suites with results and configurations.', | |
| 19 | + hardware: 'GPUs, accelerators and devices with memory and bandwidth specs.', | |
| 20 | + framework: 'Training, inference and agent frameworks.', | |
| 21 | + dataset: 'Training and evaluation datasets.', | |
| 22 | + tool: 'Developer tools, agents and MCP servers.', | |
| 23 | + repository: 'Code repositories linked to models and frameworks.', | |
| 24 | + regulation: 'Laws, standards and policy instruments affecting AI.', | |
| 25 | + incident: 'Reported incidents and safety events.', | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export default async function ExplorePage() { | |
| 29 | + const res = await safe(api.exploreTypes()); | |
| 30 | + const items = (res?.items ?? []).slice().sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)); | |
| 31 | + const total = items.reduce((n, t) => n + (num(t.count) ?? 0), 0); | |
| 32 | + return ( | |
| 33 | + <Container> | |
| 34 | + <PageHeader eyebrow="Explore" title="The atlas by entity type" lede="Counts are live from the graph. Types without a dedicated section open a generic listing." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} entities</p> : undefined} /> | |
| 35 | + <div className="pb-16"> | |
| 36 | + {!res ? ( | |
| 37 | + <Unavailable what="Entity types" /> | |
| 38 | + ) : ( | |
| 39 | + <ul className="grid border-l border-t border-rule sm:grid-cols-2 lg:grid-cols-3"> | |
| 40 | + {items.map((t) => ( | |
| 41 | + <li key={t.entity_type} className="border-b border-r border-rule"> | |
| 42 | + <Link href={routes.listing(t.entity_type)} className="flex h-full flex-col px-5 py-5 hover:bg-surface-2"> | |
| 43 | + <div className="flex items-center justify-between"> | |
| 44 | + <EntityBadge type={t.entity_type} /> | |
| 45 | + <span className="tnum text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</span> | |
| 46 | + </div> | |
| 47 | + <p className="mt-3 font-medium">{t.label || typeLabel(t.entity_type, true)}</p> | |
| 48 | + <p className="mt-1 text-sm text-ink-2">{BLURB[t.entity_type] ?? `All ${typeLabel(t.entity_type, true).toLowerCase()} in the graph.`}</p> | |
| 49 | + </Link> | |
| 50 | + </li> | |
| 51 | + ))} | |
| 52 | + </ul> | |
| 53 | + )} | |
| 54 | + </div> | |
| 55 | + </Container> | |
| 56 | + ); | |
| 57 | +} | |
added
apps/web/src/app/frameworks/page.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | + | |
| 4 | +export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes', description: 'Training, inference and agent frameworks with versions, licenses and repository metrics from their official sources.', alternates: { canonical: '/frameworks' } }; | |
| 5 | +export const revalidate = 300; | |
| 6 | + | |
| 7 | +export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 8 | + const sp = await searchParams; | |
| 9 | + return <GenericListing type="framework" basePath="/frameworks" eyebrow="Frameworks" title="Frameworks & runtimes" lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages." searchParams={sp} />; | |
| 10 | +} | |
added
apps/web/src/app/globals.css
+495 −0
@@ -0,0 +1,495 @@ | ||
| 1 | +@import 'tailwindcss'; | |
| 2 | + | |
| 3 | +/* | |
| 4 | + AI Atlas design tokens — institutional, dense, readable. Two themes on <html data-theme="light|dark">. | |
| 5 | + Light: warm paper canvas, near-black ink, atlas blue accent. Dark: graphite canvas, off-white ink, brighter blue. | |
| 6 | + Amber (accent-2) is reserved for money and change; positive/warning/danger are semantic only. | |
| 7 | + Tier colours grade source quality (1 official → 4 unverified); entity-type colours identify badges everywhere. | |
| 8 | +*/ | |
| 9 | + | |
| 10 | +:root, | |
| 11 | +[data-theme='light'] { | |
| 12 | + color-scheme: light; | |
| 13 | + --canvas: #f6f6f3; | |
| 14 | + --surface: #ffffff; | |
| 15 | + --surface-2: #eeeeea; | |
| 16 | + --surface-3: #e3e4de; | |
| 17 | + --ink: #101216; | |
| 18 | + --ink-2: #4b5161; | |
| 19 | + --ink-3: #7c8293; | |
| 20 | + --rule: rgba(16, 18, 22, 0.1); | |
| 21 | + --rule-strong: rgba(16, 18, 22, 0.22); | |
| 22 | + --accent: #1f4fd8; | |
| 23 | + --accent-ink: #ffffff; | |
| 24 | + --accent-soft: rgba(31, 79, 216, 0.1); | |
| 25 | + --accent-2: #a8600a; | |
| 26 | + --accent-2-soft: rgba(212, 130, 22, 0.14); | |
| 27 | + --positive: #1d7a4a; | |
| 28 | + --positive-soft: rgba(29, 122, 74, 0.12); | |
| 29 | + --warning: #b4700e; | |
| 30 | + --warning-soft: rgba(212, 130, 22, 0.14); | |
| 31 | + --danger: #c0333d; | |
| 32 | + --danger-soft: rgba(192, 51, 61, 0.12); | |
| 33 | + | |
| 34 | + --tier-1: #1d7a4a; | |
| 35 | + --tier-2: #1f4fd8; | |
| 36 | + --tier-3: #b4700e; | |
| 37 | + --tier-4: #7c8293; | |
| 38 | + | |
| 39 | + --type-model: #1f4fd8; | |
| 40 | + --type-company: #6d3fc7; | |
| 41 | + --type-paper: #b6337e; | |
| 42 | + --type-provider: #0d7c86; | |
| 43 | + --type-benchmark: #c2560f; | |
| 44 | + --type-hardware: #3d7a1d; | |
| 45 | + --type-framework: #0f6fa8; | |
| 46 | + --type-dataset: #8a3fb0; | |
| 47 | + --type-tool: #5b6478; | |
| 48 | + | |
| 49 | + --series-1: #1f4fd8; | |
| 50 | + --series-2: #c2560f; | |
| 51 | + --series-3: #1d7a4a; | |
| 52 | + --series-4: #6d3fc7; | |
| 53 | + --series-5: #0d7c86; | |
| 54 | + --series-6: #b6337e; | |
| 55 | + --series-7: #7c8293; | |
| 56 | + --series-8: #c0333d; | |
| 57 | + | |
| 58 | + --radius: 4px; | |
| 59 | + --radius-lg: 8px; | |
| 60 | + --header-h: 56px; | |
| 61 | + --tabbar-h: 58px; | |
| 62 | +} | |
| 63 | + | |
| 64 | +[data-theme='dark'] { | |
| 65 | + color-scheme: dark; | |
| 66 | + --canvas: #0b0d11; | |
| 67 | + --surface: #12151b; | |
| 68 | + --surface-2: #191d25; | |
| 69 | + --surface-3: #222732; | |
| 70 | + --ink: #e9ebf0; | |
| 71 | + --ink-2: #a3a9b8; | |
| 72 | + --ink-3: #6f7688; | |
| 73 | + --rule: rgba(190, 200, 225, 0.11); | |
| 74 | + --rule-strong: rgba(190, 200, 225, 0.26); | |
| 75 | + --accent: #6d95ff; | |
| 76 | + --accent-ink: #061024; | |
| 77 | + --accent-soft: rgba(109, 149, 255, 0.14); | |
| 78 | + --accent-2: #f2b04a; | |
| 79 | + --accent-2-soft: rgba(242, 176, 74, 0.14); | |
| 80 | + --positive: #45c47f; | |
| 81 | + --positive-soft: rgba(69, 196, 127, 0.14); | |
| 82 | + --warning: #f2b04a; | |
| 83 | + --warning-soft: rgba(242, 176, 74, 0.14); | |
| 84 | + --danger: #ff6b74; | |
| 85 | + --danger-soft: rgba(255, 107, 116, 0.14); | |
| 86 | + | |
| 87 | + --tier-1: #45c47f; | |
| 88 | + --tier-2: #6d95ff; | |
| 89 | + --tier-3: #f2b04a; | |
| 90 | + --tier-4: #8b92a5; | |
| 91 | + | |
| 92 | + --type-model: #6d95ff; | |
| 93 | + --type-company: #b08cff; | |
| 94 | + --type-paper: #ff7ab6; | |
| 95 | + --type-provider: #3fc3cf; | |
| 96 | + --type-benchmark: #ff9a4d; | |
| 97 | + --type-hardware: #8fd35b; | |
| 98 | + --type-framework: #57b7f0; | |
| 99 | + --type-dataset: #d38cff; | |
| 100 | + --type-tool: #9aa3b8; | |
| 101 | + | |
| 102 | + --series-1: #6d95ff; | |
| 103 | + --series-2: #ff9a4d; | |
| 104 | + --series-3: #45c47f; | |
| 105 | + --series-4: #b08cff; | |
| 106 | + --series-5: #3fc3cf; | |
| 107 | + --series-6: #ff7ab6; | |
| 108 | + --series-7: #9aa3b8; | |
| 109 | + --series-8: #ff6b74; | |
| 110 | +} | |
| 111 | + | |
| 112 | +@theme inline { | |
| 113 | + --color-canvas: var(--canvas); | |
| 114 | + --color-surface: var(--surface); | |
| 115 | + --color-surface-2: var(--surface-2); | |
| 116 | + --color-surface-3: var(--surface-3); | |
| 117 | + --color-ink: var(--ink); | |
| 118 | + --color-ink-2: var(--ink-2); | |
| 119 | + --color-ink-3: var(--ink-3); | |
| 120 | + --color-rule: var(--rule); | |
| 121 | + --color-rule-strong: var(--rule-strong); | |
| 122 | + --color-accent: var(--accent); | |
| 123 | + --color-accent-ink: var(--accent-ink); | |
| 124 | + --color-accent-soft: var(--accent-soft); | |
| 125 | + --color-accent-2: var(--accent-2); | |
| 126 | + --color-accent-2-soft: var(--accent-2-soft); | |
| 127 | + --color-positive: var(--positive); | |
| 128 | + --color-positive-soft: var(--positive-soft); | |
| 129 | + --color-warning: var(--warning); | |
| 130 | + --color-warning-soft: var(--warning-soft); | |
| 131 | + --color-danger: var(--danger); | |
| 132 | + --color-danger-soft: var(--danger-soft); | |
| 133 | + --color-tier-1: var(--tier-1); | |
| 134 | + --color-tier-2: var(--tier-2); | |
| 135 | + --color-tier-3: var(--tier-3); | |
| 136 | + --color-tier-4: var(--tier-4); | |
| 137 | + --color-type-model: var(--type-model); | |
| 138 | + --color-type-company: var(--type-company); | |
| 139 | + --color-type-paper: var(--type-paper); | |
| 140 | + --color-type-provider: var(--type-provider); | |
| 141 | + --color-type-benchmark: var(--type-benchmark); | |
| 142 | + --color-type-hardware: var(--type-hardware); | |
| 143 | + --color-type-framework: var(--type-framework); | |
| 144 | + --color-type-dataset: var(--type-dataset); | |
| 145 | + --color-type-tool: var(--type-tool); | |
| 146 | + --color-series-1: var(--series-1); | |
| 147 | + --color-series-2: var(--series-2); | |
| 148 | + --color-series-3: var(--series-3); | |
| 149 | + --color-series-4: var(--series-4); | |
| 150 | + --color-series-5: var(--series-5); | |
| 151 | + --color-series-6: var(--series-6); | |
| 152 | + --color-series-7: var(--series-7); | |
| 153 | + --color-series-8: var(--series-8); | |
| 154 | + | |
| 155 | + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; | |
| 156 | + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 157 | + | |
| 158 | + --radius-sm: var(--radius); | |
| 159 | + --radius-md: var(--radius); | |
| 160 | + --radius-lg: var(--radius-lg); | |
| 161 | + --radius-xl: 12px; | |
| 162 | + | |
| 163 | + --text-2xs: 0.6875rem; | |
| 164 | + --text-2xs--line-height: 1rem; | |
| 165 | +} | |
| 166 | + | |
| 167 | +@layer base { | |
| 168 | + html { | |
| 169 | + background: var(--canvas); | |
| 170 | + color: var(--ink); | |
| 171 | + -webkit-text-size-adjust: 100%; | |
| 172 | + text-rendering: optimizeLegibility; | |
| 173 | + font-feature-settings: 'cv11', 'ss01'; | |
| 174 | + scroll-padding-top: calc(var(--header-h) + 12px); | |
| 175 | + } | |
| 176 | + body { | |
| 177 | + font-family: var(--font-sans); | |
| 178 | + font-size: 15px; | |
| 179 | + line-height: 1.5; | |
| 180 | + background: var(--canvas); | |
| 181 | + color: var(--ink); | |
| 182 | + } | |
| 183 | + ::selection { | |
| 184 | + background: var(--accent-soft); | |
| 185 | + } | |
| 186 | + :focus-visible { | |
| 187 | + outline: 2px solid var(--accent); | |
| 188 | + outline-offset: 2px; | |
| 189 | + } | |
| 190 | + h1, | |
| 191 | + h2, | |
| 192 | + h3, | |
| 193 | + h4 { | |
| 194 | + letter-spacing: -0.01em; | |
| 195 | + text-wrap: balance; | |
| 196 | + } | |
| 197 | + code, | |
| 198 | + kbd, | |
| 199 | + pre, | |
| 200 | + samp { | |
| 201 | + font-family: var(--font-mono); | |
| 202 | + } | |
| 203 | + table { | |
| 204 | + font-variant-numeric: tabular-nums; | |
| 205 | + } | |
| 206 | + a { | |
| 207 | + text-underline-offset: 3px; | |
| 208 | + } | |
| 209 | + button { | |
| 210 | + cursor: pointer; | |
| 211 | + } | |
| 212 | + input, | |
| 213 | + select, | |
| 214 | + textarea { | |
| 215 | + font: inherit; | |
| 216 | + color: inherit; | |
| 217 | + } | |
| 218 | + input::placeholder { | |
| 219 | + color: var(--ink-3); | |
| 220 | + } | |
| 221 | + input[type='search']::-webkit-search-decoration, | |
| 222 | + input[type='search']::-webkit-search-cancel-button { | |
| 223 | + -webkit-appearance: none; | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/* ---------------------------------------------------------------------------------------------------------- utilities */ | |
| 228 | +.container-x { | |
| 229 | + padding-left: max(1rem, env(safe-area-inset-left)); | |
| 230 | + padding-right: max(1rem, env(safe-area-inset-right)); | |
| 231 | +} | |
| 232 | +@media (min-width: 768px) { | |
| 233 | + .container-x { | |
| 234 | + padding-left: 2rem; | |
| 235 | + padding-right: 2rem; | |
| 236 | + } | |
| 237 | +} | |
| 238 | + | |
| 239 | +.eyebrow { | |
| 240 | + font-size: 0.6875rem; | |
| 241 | + line-height: 1rem; | |
| 242 | + letter-spacing: 0.1em; | |
| 243 | + text-transform: uppercase; | |
| 244 | + font-weight: 600; | |
| 245 | + color: var(--ink-3); | |
| 246 | +} | |
| 247 | +.display { | |
| 248 | + font-weight: 600; | |
| 249 | + letter-spacing: -0.03em; | |
| 250 | + line-height: 1.02; | |
| 251 | + text-wrap: balance; | |
| 252 | +} | |
| 253 | +.mono { | |
| 254 | + font-family: var(--font-mono); | |
| 255 | + font-feature-settings: 'tnum', 'zero'; | |
| 256 | +} | |
| 257 | +.tnum { | |
| 258 | + font-variant-numeric: tabular-nums; | |
| 259 | + font-feature-settings: 'tnum'; | |
| 260 | +} | |
| 261 | +.hairline { | |
| 262 | + border-top: 1px solid var(--rule); | |
| 263 | +} | |
| 264 | +.hairline-strong { | |
| 265 | + border-top: 1px solid var(--rule-strong); | |
| 266 | +} | |
| 267 | +.link { | |
| 268 | + color: var(--accent); | |
| 269 | + text-decoration: none; | |
| 270 | +} | |
| 271 | +.link:hover { | |
| 272 | + text-decoration: underline; | |
| 273 | +} | |
| 274 | +/* Bordered surface — use sparingly (dialogs, sheets, the odd callout). Prefer hairlines and spatial composition. */ | |
| 275 | +.panel { | |
| 276 | + background: var(--surface); | |
| 277 | + border: 1px solid var(--rule); | |
| 278 | + border-radius: var(--radius-lg); | |
| 279 | +} | |
| 280 | +.grid-bg { | |
| 281 | + background-image: linear-gradient(to right, var(--rule) 1px, transparent 1px), linear-gradient(to bottom, var(--rule) 1px, transparent 1px); | |
| 282 | + background-size: 48px 48px; | |
| 283 | + mask-image: radial-gradient(ellipse at 50% 0%, rgba(0, 0, 0, 0.9), transparent 75%); | |
| 284 | +} | |
| 285 | +.scrollbar-thin { | |
| 286 | + scrollbar-width: thin; | |
| 287 | + scrollbar-color: var(--rule-strong) transparent; | |
| 288 | +} | |
| 289 | +.scrollbar-thin::-webkit-scrollbar { | |
| 290 | + height: 6px; | |
| 291 | + width: 6px; | |
| 292 | +} | |
| 293 | +.scrollbar-thin::-webkit-scrollbar-thumb { | |
| 294 | + background: var(--rule-strong); | |
| 295 | + border-radius: 3px; | |
| 296 | +} | |
| 297 | +.no-scrollbar { | |
| 298 | + scrollbar-width: none; | |
| 299 | +} | |
| 300 | +.no-scrollbar::-webkit-scrollbar { | |
| 301 | + display: none; | |
| 302 | +} | |
| 303 | +.safe-bottom { | |
| 304 | + padding-bottom: env(safe-area-inset-bottom, 0px); | |
| 305 | +} | |
| 306 | +.prose-atlas p + p { | |
| 307 | + margin-top: 0.75rem; | |
| 308 | +} | |
| 309 | +.prose-atlas a { | |
| 310 | + color: var(--accent); | |
| 311 | +} | |
| 312 | +.prose-atlas a:hover { | |
| 313 | + text-decoration: underline; | |
| 314 | +} | |
| 315 | + | |
| 316 | +/* Live indicator */ | |
| 317 | +.dot { | |
| 318 | + display: inline-block; | |
| 319 | + width: 7px; | |
| 320 | + height: 7px; | |
| 321 | + border-radius: 999px; | |
| 322 | + background: var(--positive); | |
| 323 | + vertical-align: middle; | |
| 324 | +} | |
| 325 | +.pulse { | |
| 326 | + position: relative; | |
| 327 | +} | |
| 328 | +.pulse::after { | |
| 329 | + content: ''; | |
| 330 | + position: absolute; | |
| 331 | + inset: -3px; | |
| 332 | + border-radius: 999px; | |
| 333 | + border: 1px solid var(--positive); | |
| 334 | + animation: pulse-ring 1.8s ease-out infinite; | |
| 335 | +} | |
| 336 | +@keyframes pulse-ring { | |
| 337 | + 0% { | |
| 338 | + transform: scale(0.6); | |
| 339 | + opacity: 0.9; | |
| 340 | + } | |
| 341 | + 100% { | |
| 342 | + transform: scale(1.8); | |
| 343 | + opacity: 0; | |
| 344 | + } | |
| 345 | +} | |
| 346 | + | |
| 347 | +/* ---------------------------------------------------------------------------------------------------------- data tables */ | |
| 348 | +.data-table { | |
| 349 | + width: 100%; | |
| 350 | + border-collapse: collapse; | |
| 351 | + font-variant-numeric: tabular-nums; | |
| 352 | + font-size: 0.875rem; | |
| 353 | +} | |
| 354 | +.data-table th { | |
| 355 | + text-align: left; | |
| 356 | + font-weight: 600; | |
| 357 | + font-size: 0.6875rem; | |
| 358 | + letter-spacing: 0.08em; | |
| 359 | + text-transform: uppercase; | |
| 360 | + color: var(--ink-3); | |
| 361 | + padding: 0.5rem 0.75rem; | |
| 362 | + border-bottom: 1px solid var(--rule-strong); | |
| 363 | + white-space: nowrap; | |
| 364 | + vertical-align: bottom; | |
| 365 | +} | |
| 366 | +.data-table th:first-child, | |
| 367 | +.data-table td:first-child { | |
| 368 | + padding-left: 0; | |
| 369 | +} | |
| 370 | +.data-table th:last-child, | |
| 371 | +.data-table td:last-child { | |
| 372 | + padding-right: 0; | |
| 373 | +} | |
| 374 | +.data-table td { | |
| 375 | + padding: 0.55rem 0.75rem; | |
| 376 | + border-bottom: 1px solid var(--rule); | |
| 377 | + vertical-align: middle; | |
| 378 | +} | |
| 379 | +.data-table tbody tr:hover { | |
| 380 | + background: var(--surface-2); | |
| 381 | +} | |
| 382 | +.data-table .num { | |
| 383 | + text-align: right; | |
| 384 | + font-variant-numeric: tabular-nums; | |
| 385 | +} | |
| 386 | +.data-table th.num { | |
| 387 | + text-align: right; | |
| 388 | +} | |
| 389 | +.data-table .primary { | |
| 390 | + font-weight: 500; | |
| 391 | + color: var(--ink); | |
| 392 | +} | |
| 393 | +.data-table.compact td { | |
| 394 | + padding-top: 0.35rem; | |
| 395 | + padding-bottom: 0.35rem; | |
| 396 | +} | |
| 397 | +.data-table a.row-link { | |
| 398 | + color: inherit; | |
| 399 | + text-decoration: none; | |
| 400 | +} | |
| 401 | +.data-table a.row-link:hover { | |
| 402 | + color: var(--accent); | |
| 403 | +} | |
| 404 | +@media (max-width: 767px) { | |
| 405 | + .data-table.stack thead { | |
| 406 | + display: none; | |
| 407 | + } | |
| 408 | + .data-table.stack tr { | |
| 409 | + display: grid; | |
| 410 | + grid-template-columns: 1fr 1fr; | |
| 411 | + gap: 0.25rem 0.75rem; | |
| 412 | + padding: 0.75rem 0; | |
| 413 | + border-bottom: 1px solid var(--rule); | |
| 414 | + } | |
| 415 | + .data-table.stack tbody tr:hover { | |
| 416 | + background: transparent; | |
| 417 | + } | |
| 418 | + .data-table.stack td { | |
| 419 | + display: block; | |
| 420 | + border: 0; | |
| 421 | + padding: 0; | |
| 422 | + min-width: 0; | |
| 423 | + overflow-wrap: anywhere; | |
| 424 | + } | |
| 425 | + .data-table.stack td[data-label]::before { | |
| 426 | + content: attr(data-label); | |
| 427 | + display: block; | |
| 428 | + font-size: 0.625rem; | |
| 429 | + letter-spacing: 0.08em; | |
| 430 | + text-transform: uppercase; | |
| 431 | + color: var(--ink-3); | |
| 432 | + font-weight: 600; | |
| 433 | + margin-bottom: 0.1rem; | |
| 434 | + } | |
| 435 | + .data-table.stack td.primary { | |
| 436 | + grid-column: 1 / -1; | |
| 437 | + font-size: 0.9375rem; | |
| 438 | + } | |
| 439 | + .data-table.stack td.num { | |
| 440 | + text-align: left; | |
| 441 | + } | |
| 442 | + .data-table.stack td.wide { | |
| 443 | + grid-column: 1 / -1; | |
| 444 | + } | |
| 445 | + .data-table.stack td.hide-stack { | |
| 446 | + display: none; | |
| 447 | + } | |
| 448 | +} | |
| 449 | +/* Horizontal scroll wrapper for wide tables that must not stack. */ | |
| 450 | +.table-scroll { | |
| 451 | + overflow-x: auto; | |
| 452 | + -webkit-overflow-scrolling: touch; | |
| 453 | + scrollbar-width: thin; | |
| 454 | +} | |
| 455 | +.table-scroll .data-table th, | |
| 456 | +.table-scroll .data-table td { | |
| 457 | + white-space: nowrap; | |
| 458 | +} | |
| 459 | + | |
| 460 | +/* ---------------------------------------------------------------------------------------------------------- key–value */ | |
| 461 | +.kv { | |
| 462 | + display: grid; | |
| 463 | + grid-template-columns: minmax(0, 1fr); | |
| 464 | + font-size: 0.875rem; | |
| 465 | +} | |
| 466 | +.kv > div { | |
| 467 | + display: grid; | |
| 468 | + grid-template-columns: 9.5rem minmax(0, 1fr); | |
| 469 | + gap: 0.25rem 1rem; | |
| 470 | + padding: 0.5rem 0; | |
| 471 | + border-bottom: 1px solid var(--rule); | |
| 472 | + align-items: baseline; | |
| 473 | +} | |
| 474 | +.kv > div > dt { | |
| 475 | + color: var(--ink-3); | |
| 476 | + font-size: 0.8125rem; | |
| 477 | +} | |
| 478 | +.kv > div > dd { | |
| 479 | + min-width: 0; | |
| 480 | + overflow-wrap: anywhere; | |
| 481 | +} | |
| 482 | +@media (max-width: 480px) { | |
| 483 | + .kv > div { | |
| 484 | + grid-template-columns: 7.5rem minmax(0, 1fr); | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 488 | +@media (prefers-reduced-motion: reduce) { | |
| 489 | + *, | |
| 490 | + *::before, | |
| 491 | + *::after { | |
| 492 | + animation-duration: 0.01ms !important; | |
| 493 | + transition-duration: 0.01ms !important; | |
| 494 | + } | |
| 495 | +} | |
added
apps/web/src/app/hardware/page.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 4 | +import { api } from '@/lib/api'; | |
| 5 | + | |
| 6 | +export const metadata: Metadata = { title: 'AI hardware — GPUs, accelerators, memory and bandwidth', description: 'Accelerators and devices for training and inference: memory, bandwidth, TDP, runtimes — and which models are estimated to fit.', alternates: { canonical: '/hardware' } }; | |
| 7 | +export const revalidate = 600; | |
| 8 | + | |
| 9 | +export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 10 | + const sp = await searchParams; | |
| 11 | + return ( | |
| 12 | + <GenericListing type="hardware" basePath="/hardware" eyebrow="Hardware" title="Hardware" lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers." searchParams={sp} fetch={(q) => api.hardware(q)} sorts={[{ value: 'memory', label: 'Memory' }, { value: 'name', label: 'Name' }, { value: 'updated', label: 'Recently updated' }]} extraFields={[{ kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' }, { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' }]}> | |
| 13 | + <p className="mb-6 text-sm text-ink-2"> | |
| 14 | + Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. | |
| 15 | + </p> | |
| 16 | + </GenericListing> | |
| 17 | + ); | |
| 18 | +} | |
added
apps/web/src/app/icon.svg
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"> | |
| 2 | + <rect width="32" height="32" rx="6" fill="#0b0d11"/> | |
| 3 | + <circle cx="16" cy="16" r="11.5" stroke="#e9ebf0" stroke-width="1.5"/> | |
| 4 | + <path d="M16 4.5c-3.9 2.9-5.8 6.7-5.8 11.5s1.9 8.6 5.8 11.5M16 4.5c3.9 2.9 5.8 6.7 5.8 11.5s-1.9 8.6-5.8 11.5M4.5 16h23" stroke="#e9ebf0" stroke-width="1.1" opacity="0.7"/> | |
| 5 | + <path d="M10.2 10.3L16 16l5.8-5.7M16 16l-4.6 7.4M16 16l6.5 5" stroke="#e9ebf0" stroke-width="1.1" opacity="0.55"/> | |
| 6 | + <circle cx="16" cy="16" r="2.3" fill="#6d95ff"/> | |
| 7 | + <circle cx="10.2" cy="10.3" r="1.6" fill="#e9ebf0"/> | |
| 8 | + <circle cx="21.8" cy="10.3" r="1.6" fill="#e9ebf0"/> | |
| 9 | + <circle cx="11.4" cy="23.4" r="1.6" fill="#e9ebf0"/> | |
| 10 | + <circle cx="22.5" cy="21" r="1.6" fill="#e9ebf0"/> | |
| 11 | +</svg> | |
added
apps/web/src/app/layout.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import type { Metadata, Viewport } from 'next'; | |
| 2 | +import Script from 'next/script'; | |
| 3 | +import './globals.css'; | |
| 4 | +import { MobileTabBar } from '@/components/layout/mobile-tab-bar'; | |
| 5 | +import { SearchProvider } from '@/components/layout/search-context'; | |
| 6 | +import { SearchDialog } from '@/components/layout/search-dialog'; | |
| 7 | +import { SiteFooter } from '@/components/layout/site-footer'; | |
| 8 | +import { SiteHeader } from '@/components/layout/site-header'; | |
| 9 | +import { THEME_SCRIPT } from '@/components/layout/theme'; | |
| 10 | +import { fontMono, fontUi } from '@/lib/fonts'; | |
| 11 | +import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { | |
| 14 | + metadataBase: new URL(SITE_URL), | |
| 15 | + title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s | ${SITE_NAME}` }, | |
| 16 | + description: DESCRIPTION, | |
| 17 | + applicationName: SITE_NAME, | |
| 18 | + robots: { index: true, follow: true }, | |
| 19 | + alternates: { canonical: '/' }, | |
| 20 | + openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION }, | |
| 21 | + twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION }, | |
| 22 | + icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }], apple: [{ url: '/apple-icon', sizes: '180x180', type: 'image/png' }] }, | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export const viewport: Viewport = { | |
| 26 | + width: 'device-width', | |
| 27 | + initialScale: 1, | |
| 28 | + viewportFit: 'cover', | |
| 29 | + themeColor: [ | |
| 30 | + { media: '(prefers-color-scheme: light)', color: '#f6f6f3' }, | |
| 31 | + { media: '(prefers-color-scheme: dark)', color: '#0b0d11' }, | |
| 32 | + ], | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export default function RootLayout({ children }: { children: React.ReactNode }) { | |
| 36 | + return ( | |
| 37 | + <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`} suppressHydrationWarning> | |
| 38 | + <head> | |
| 39 | + <Script id="aia-theme" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} /> | |
| 40 | + </head> | |
| 41 | + <body className="flex min-h-full flex-col pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px))] md:pb-0"> | |
| 42 | + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink"> | |
| 43 | + Skip to content | |
| 44 | + </a> | |
| 45 | + <SearchProvider> | |
| 46 | + <SiteHeader /> | |
| 47 | + <main id="main" className="flex-1"> | |
| 48 | + {children} | |
| 49 | + </main> | |
| 50 | + <SiteFooter /> | |
| 51 | + <MobileTabBar /> | |
| 52 | + <SearchDialog /> | |
| 53 | + </SearchProvider> | |
| 54 | + </body> | |
| 55 | + </html> | |
| 56 | + ); | |
| 57 | +} | |
added
apps/web/src/app/methodology/page.tsx
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { TierBadge } from '@/components/ui/badges'; | |
| 4 | +import { DataTable, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { Container, PageHeader, Section } from '@/components/ui/section'; | |
| 6 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 7 | +import { api, safe } from '@/lib/api'; | |
| 8 | +import { fmtAgo, fmtInt, humanize } from '@/lib/format'; | |
| 9 | +import { eventLabel, routes, TIER_LABELS } from '@/lib/site'; | |
| 10 | +import type { Methodology } from '@/lib/types'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { title: 'Methodology — provenance, tiers, confidence, quality score, events', description: 'How AI Atlas records facts: source tiers, confidence levels, temporal claims, conflict handling, the data-quality score, change events and estimates.', alternates: { canonical: '/methodology' } }; | |
| 13 | +export const revalidate = 3600; | |
| 14 | + | |
| 15 | +/** The API may return dicts or lists for these vocabularies; normalise to rows. */ | |
| 16 | +function rows(v: unknown, keyName: string): { key: string; label?: string; description?: string; extra?: Record<string, unknown> }[] { | |
| 17 | + if (!v) return []; | |
| 18 | + if (Array.isArray(v)) return v.map((x) => (typeof x === 'string' ? { key: x } : { key: String((x as Record<string, unknown>)[keyName] ?? (x as Record<string, unknown>).key ?? (x as Record<string, unknown>).name ?? ''), label: (x as Record<string, unknown>).label as string | undefined, description: ((x as Record<string, unknown>).description ?? (x as Record<string, unknown>).text) as string | undefined, extra: x as Record<string, unknown> })); | |
| 19 | + if (typeof v === 'object') return Object.entries(v as Record<string, unknown>).map(([k, d]) => (typeof d === 'string' ? { key: k, description: d } : { key: k, ...(d as Record<string, unknown>), extra: d as Record<string, unknown> })); | |
| 20 | + return []; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export default async function MethodologyPage() { | |
| 24 | + const m: Methodology | null = await safe(api.methodology()); | |
| 25 | + const tiers = rows(m?.tiers, 'tier'); | |
| 26 | + const conf = rows(m?.confidence_levels, 'key'); | |
| 27 | + const events = rows(m?.event_types, 'event_type'); | |
| 28 | + const extractors = rows(m?.extractors, 'key'); | |
| 29 | + return ( | |
| 30 | + <Container> | |
| 31 | + <PageHeader eyebrow="Methodology" title="How AI Atlas records facts" lede="The dataset is the product. These are the rules every connector, extractor and page follows — and the vocabularies the API exposes." /> | |
| 32 | + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2"> | |
| 33 | + <p>AI Atlas is built from first-party connectors that read public documents directly: official documentation, pricing pages, model cards, release notes, papers, feeds, sitemaps and repositories. Nothing depends on a third-party data API. Every document is snapshotted and archived; every fact points back to a snapshot.</p> | |
| 34 | + <p>Facts are <strong className="text-ink">temporal claims</strong>: a property, a value, a source, a tier, a confidence, an extractor and a validity interval. When a better-or-equal source states a new value, the old claim is superseded (its interval closes) and the new one becomes current. When a worse source disagrees, the value is stored as <em>conflicting</em> and flagged for review — it is never averaged or silently overwritten. Missing means missing: the site shows “Unavailable” rather than a guess.</p> | |
| 35 | + </div> | |
| 36 | + | |
| 37 | + <Section id="tiers" eyebrow="Source tiers" title="Primary sources first"> | |
| 38 | + {m && tiers.length ? ( | |
| 39 | + <DataTable caption="Source tiers"> | |
| 40 | + <thead><tr><Th>Tier</Th><Th>Meaning</Th><Th>Description</Th></tr></thead> | |
| 41 | + <tbody> | |
| 42 | + {tiers.map((t, i) => ( | |
| 43 | + <tr key={`${t.key}-${i}`}> | |
| 44 | + <Td primary><TierBadge tier={Number(t.key)} /></Td> | |
| 45 | + <Td label="Meaning" className="text-ink">{t.label ?? TIER_LABELS[Number(t.key)] ?? humanize(t.key)}</Td> | |
| 46 | + <Td label="Description" wide className="text-ink-2">{t.description ?? '—'}</Td> | |
| 47 | + </tr> | |
| 48 | + ))} | |
| 49 | + </tbody> | |
| 50 | + </DataTable> | |
| 51 | + ) : m ? ( | |
| 52 | + <ul className="space-y-1 text-sm text-ink-2">{[1, 2, 3, 4].map((t) => <li key={t} className="flex items-center gap-2"><TierBadge tier={t} /> {TIER_LABELS[t]}</li>)}</ul> | |
| 53 | + ) : ( | |
| 54 | + <Unavailable what="Tier vocabulary" compact /> | |
| 55 | + )} | |
| 56 | + <p className="mt-3 max-w-3xl text-sm text-ink-2">A tier 1 source is the entity's own publisher (a lab's documentation, a provider's pricing page, an arXiv listing for a paper). Tier 2 are quality secondary sources, tier 3 community sources, tier 4 unverified. A higher tier can supersede a lower one; the reverse produces a flagged conflict.</p> | |
| 57 | + </Section> | |
| 58 | + | |
| 59 | + <Section id="confidence" eyebrow="Confidence" title="Confidence levels"> | |
| 60 | + {m && conf.length ? ( | |
| 61 | + <dl className="kv max-w-3xl"> | |
| 62 | + {conf.map((c, i) => ( | |
| 63 | + <div key={`${c.key}-${i}`}> | |
| 64 | + <dt className="mono">{c.key}</dt> | |
| 65 | + <dd className="text-ink-2">{c.description ?? c.label ?? '—'}</dd> | |
| 66 | + </div> | |
| 67 | + ))} | |
| 68 | + </dl> | |
| 69 | + ) : ( | |
| 70 | + <Unavailable what="Confidence vocabulary" compact /> | |
| 71 | + )} | |
| 72 | + <p className="mt-3 max-w-3xl text-sm text-ink-2">Default confidence follows the tier (tier 1 → high, tier 2 → medium, tiers 3–4 → low). A claim becomes <em>conflicted</em> when a current value is contradicted by another source. Extractors can raise confidence to <em>verified</em> when a value is confirmed by several independent tier 1–2 sources.</p> | |
| 73 | + </Section> | |
| 74 | + | |
| 75 | + <Section id="quality" eyebrow="Data quality" title="The quality score measures our knowledge, not the entity"> | |
| 76 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 77 | + <span className="mono text-ink">quality.score = 100 × (0.25 completeness + 0.25 primary-source ratio + 0.20 freshness + 0.15 agreement + 0.15 source diversity)</span>. It says how well AI Atlas knows an entity — how many expected attributes are filled, how much comes from tier 1, how recently it was re-observed, how few conflicts remain and how many independent sources agree. It says nothing about whether a model is good. | |
| 78 | + </p> | |
| 79 | + {m && m.metrics?.length > 0 && ( | |
| 80 | + <DataTable caption="Metric definitions" className="mt-5"> | |
| 81 | + <thead><tr><Th>Metric</Th><Th>Definition</Th><Th>Version</Th></tr></thead> | |
| 82 | + <tbody> | |
| 83 | + {m.metrics.map((x, i) => ( | |
| 84 | + <tr key={String(x.key ?? x.name ?? i)}> | |
| 85 | + <Td primary className="mono text-xs">{String(x.key ?? x.name ?? '—')}</Td> | |
| 86 | + <Td label="Definition" wide className="text-ink-2">{String(x.description ?? x.formula ?? x.label ?? '—')}{x.unit ? <span className="text-ink-3"> · {String(x.unit)}</span> : null}</Td> | |
| 87 | + <Td label="Version" className="mono text-xs text-ink-3">{x.version !== undefined ? String(x.version) : '—'}</Td> | |
| 88 | + </tr> | |
| 89 | + ))} | |
| 90 | + </tbody> | |
| 91 | + </DataTable> | |
| 92 | + )} | |
| 93 | + </Section> | |
| 94 | + | |
| 95 | + <Section id="events" eyebrow="History" title="Change events"> | |
| 96 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Material properties — context length, status, license, openness, parameters, release date, deprecation and retirement dates, versions, prices — emit events when they change. Noisy metrics (downloads, likes, stars) are stored as time series and never generate events. Descriptions and other soft text follow their own source without events. Each event has a category and an importance from 0 (minor) to 3 (major), and links to the source document that triggered it.</p> | |
| 97 | + {m && events.length ? ( | |
| 98 | + <DataTable caption="Event types" className="mt-5" compact> | |
| 99 | + <thead><tr><Th>Event type</Th><Th>Label</Th><Th>Category</Th><Th num>Recorded</Th><Th>Last seen</Th></tr></thead> | |
| 100 | + <tbody> | |
| 101 | + {events.map((e, i) => ( | |
| 102 | + <tr key={`${e.key}-${String(e.extra?.category ?? '')}-${i}`}> | |
| 103 | + <Td primary className="mono text-xs">{e.key}</Td> | |
| 104 | + <Td label="Label" className="text-ink-2">{e.label ?? e.description ?? eventLabel(e.key)}</Td> | |
| 105 | + <Td label="Category" className="text-ink-2">{String(e.extra?.category ?? '—')}</Td> | |
| 106 | + <Td num label="Recorded" className="tnum text-ink-2">{e.extra?.count !== undefined ? fmtInt(e.extra.count) : e.extra?.importance !== undefined ? `importance ${String(e.extra.importance)}` : '—'}</Td> | |
| 107 | + <Td label="Last seen" className="text-ink-2">{typeof e.extra?.last_seen_at === 'string' ? fmtAgo(e.extra.last_seen_at) : '—'}</Td> | |
| 108 | + </tr> | |
| 109 | + ))} | |
| 110 | + </tbody> | |
| 111 | + </DataTable> | |
| 112 | + ) : ( | |
| 113 | + <Unavailable what="Event vocabulary" compact className="mt-4" /> | |
| 114 | + )} | |
| 115 | + </Section> | |
| 116 | + | |
| 117 | + <Section id="extractors" eyebrow="Extraction" title="Deterministic before LLM"> | |
| 118 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Stage 1 runs on every document: DOM selectors, JSON-LD, tables, Markdown, feeds. Only documents flagged as needing it go to the local LLM factory (MacLustr, OpenAI-compatible), through versioned schemas with full token accounting. LLM-extracted values are marked as such in provenance. Improving a parser bumps its version and reprocesses archived snapshots — never a re-crawl.</p> | |
| 119 | + {m && extractors.length > 0 && ( | |
| 120 | + <dl className="kv mt-5 max-w-3xl"> | |
| 121 | + {extractors.map((x, i) => ( | |
| 122 | + <div key={`${x.key}-${i}`}> | |
| 123 | + <dt className="mono">{x.key}</dt> | |
| 124 | + <dd className="text-ink-2">{x.description ?? x.label ?? '—'}</dd> | |
| 125 | + </div> | |
| 126 | + ))} | |
| 127 | + </dl> | |
| 128 | + )} | |
| 129 | + </Section> | |
| 130 | + | |
| 131 | + <Section id="benchmarks" eyebrow="Benchmarks" title="Results are never compared blindly"> | |
| 132 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">Benchmark results are append-only and carry their configuration (harness, prompting, number of shots, judge). Leaderboards rank current rows under the benchmark's own direction; results with different configurations are shown with their config so the reader can judge comparability. We do not compute composite indices.</p> | |
| 133 | + </Section> | |
| 134 | + | |
| 135 | + <Section id="estimates" eyebrow="Estimates" title="Hardware fit is an estimate"> | |
| 136 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">The only derived figures on AI Atlas are hardware-fit estimates: memory need ≈ parameters × bytes per parameter (4-bit 0.5 × 1.15 overhead, 8-bit 1.0, fp16 2.0) plus a KV-cache allowance for the chosen context. They are labelled <span className="border border-dashed border-warning/60 px-1 text-[11px] uppercase text-warning">Estimated</span> everywhere and never mixed with observed facts.</p> | |
| 137 | + </Section> | |
| 138 | + | |
| 139 | + <Section eyebrow="Crawling" title="Respectful by design"> | |
| 140 | + <p className="max-w-3xl text-sm leading-relaxed text-ink-2"> | |
| 141 | + Connectors honour robots.txt, use per-domain rate limits and conditional requests, identify as <Link href={routes.bot()} className="link mono">AIAtlasBot</Link>, never bypass access controls and never collect private data. The list of sources and connector health is public on <Link href={routes.sources()} className="link">/sources</Link>. | |
| 142 | + </p> | |
| 143 | + </Section> | |
| 144 | + </Container> | |
| 145 | + ); | |
| 146 | +} | |
added
apps/web/src/app/models/(list)/loading.tsx
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import { Container } from '@/components/ui/section'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <Container wide className="py-10" aria-busy="true"> | |
| 6 | + <div className="h-3 w-16 animate-pulse bg-surface-3" /> | |
| 7 | + <div className="mt-3 h-9 w-56 animate-pulse bg-surface-3" /> | |
| 8 | + <div className="mt-8 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6"> | |
| 9 | + {Array.from({ length: 6 }).map((_, i) => ( | |
| 10 | + <div key={i} className="h-10 animate-pulse bg-surface-2" /> | |
| 11 | + ))} | |
| 12 | + </div> | |
| 13 | + <div className="mt-8 space-y-px"> | |
| 14 | + {Array.from({ length: 12 }).map((_, i) => ( | |
| 15 | + <div key={i} className="h-10 animate-pulse bg-surface-2/70" /> | |
| 16 | + ))} | |
| 17 | + </div> | |
| 18 | + </Container> | |
| 19 | + ); | |
| 20 | +} | |
added
apps/web/src/app/models/(list)/page.tsx
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; | |
| 4 | +import { OpennessBadge, StatusBadge } from '@/components/ui/badges'; | |
| 5 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 6 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 7 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 8 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 9 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 10 | +import { api, safe } from '@/lib/api'; | |
| 11 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 12 | +import { OPENNESS_LABELS, routes, STATUS_LABELS } from '@/lib/site'; | |
| 13 | + | |
| 14 | +export const metadata: Metadata = { | |
| 15 | + title: 'AI models — parameters, context, openness, pricing', | |
| 16 | + description: 'Every AI model in the atlas: parameters, context window, openness, license, release date and data quality — filterable and sortable, with provenance on every value.', | |
| 17 | + alternates: { canonical: '/models' }, | |
| 18 | +}; | |
| 19 | +export const revalidate = 120; | |
| 20 | + | |
| 21 | +type SP = Record<string, string | undefined>; | |
| 22 | +const LIMIT = 50; | |
| 23 | +const KEYS = ['q', 'org', 'family', 'openness', 'modality', 'status', 'min_params', 'max_params', 'min_context', 'year_from', 'year_to', 'license', 'provider', 'sort', 'order', 'offset'] as const; | |
| 24 | +const LABELS: Record<string, string> = { q: 'query', org: 'organization', family: 'family', openness: 'openness', modality: 'modality', status: 'status', min_params: 'min params', max_params: 'max params', min_context: 'min context', year_from: 'from', year_to: 'to', license: 'license', provider: 'provider' }; | |
| 25 | +const SORTS = [ | |
| 26 | + { value: 'updated', label: 'Recently updated' }, | |
| 27 | + { value: 'release', label: 'Release date' }, | |
| 28 | + { value: 'name', label: 'Name' }, | |
| 29 | + { value: 'params', label: 'Parameters' }, | |
| 30 | + { value: 'context', label: 'Context window' }, | |
| 31 | + { value: 'quality', label: 'Data quality' }, | |
| 32 | + { value: 'downloads', label: 'Downloads' }, | |
| 33 | +]; | |
| 34 | + | |
| 35 | +/** Accept "70B" / "7b" / "70000000000" for parameter inputs; "128k" for context. */ | |
| 36 | +function parseScale(v: string | undefined): number | undefined { | |
| 37 | + if (!v) return undefined; | |
| 38 | + const m = /^\s*([\d.]+)\s*([kmbt])?\s*$/i.exec(v); | |
| 39 | + if (!m) return undefined; | |
| 40 | + const n = Number(m[1]); | |
| 41 | + const mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[(m[2] ?? '').toLowerCase() as 'k' | 'm' | 'b' | 't'] ?? 1; | |
| 42 | + return Number.isFinite(n) ? Math.round(n * mult) : undefined; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export default async function ModelsPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 46 | + const sp = await searchParams; | |
| 47 | + const current: Record<string, string | undefined> = {}; | |
| 48 | + for (const k of KEYS) if (sp[k]) current[k] = sp[k]; | |
| 49 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 50 | + const sort = current.sort ?? 'updated'; | |
| 51 | + const page = await safe(api.models({ ...current, min_params: parseScale(current.min_params), max_params: parseScale(current.max_params), min_context: parseScale(current.min_context), sort, order: current.order, limit: LIMIT, offset, facets: 1 })); | |
| 52 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/models', current, patch); | |
| 53 | + const f = page?.facets; | |
| 54 | + const facetGroups: FacetGroup[] = [ | |
| 55 | + { key: 'org', label: 'Organization', items: (f?.organizations ?? []).map((o) => ({ value: o.slug, label: o.name, count: o.count })) }, | |
| 56 | + { key: 'openness', label: 'Openness', items: (f?.openness ?? []).map((x) => ({ value: x.value, label: OPENNESS_LABELS[x.value] ?? x.value, count: x.count })) }, | |
| 57 | + { key: 'modality', label: 'Modality', items: (f?.modalities ?? []).map((x) => ({ value: x.value, count: x.count })) }, | |
| 58 | + { key: 'status', label: 'Status', items: (f?.status ?? []).map((x) => ({ value: x.value, label: STATUS_LABELS[x.value] ?? x.value, count: x.count })) }, | |
| 59 | + { key: 'family', label: 'Family', items: (f?.families ?? []).map((x) => ({ value: x.value, count: x.count })) }, | |
| 60 | + { key: 'year_from', label: 'Release year', items: (f?.years ?? []).map((x) => ({ value: String(x.value), count: x.count })) }, | |
| 61 | + { key: 'license', label: 'License', items: (f?.licenses ?? []).map((x) => ({ value: x.value, count: x.count })) }, | |
| 62 | + ]; | |
| 63 | + const sortHref = (s: string) => href({ sort: s, order: sort === s && current.order !== 'asc' ? 'asc' : undefined, offset: undefined }); | |
| 64 | + const SortTh = ({ s, children, num: n }: { s: string; children: React.ReactNode; num?: boolean }) => ( | |
| 65 | + <Th num={n} aria-sort={sort === s ? (current.order === 'asc' ? 'ascending' : 'descending') : undefined}> | |
| 66 | + <Link href={sortHref(s)} className={sort === s ? 'text-ink' : 'hover:text-ink'}> | |
| 67 | + {children} | |
| 68 | + {sort === s && <span aria-hidden> {current.order === 'asc' ? '↑' : '↓'}</span>} | |
| 69 | + </Link> | |
| 70 | + </Th> | |
| 71 | + ); | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <Container wide> | |
| 75 | + <PageHeader eyebrow="Models" title="AI models" lede="Foundation and fine-tuned models across every lab and modality. Parameters, context and prices are shown exactly as stated by their sources." aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} models</p> : undefined}> | |
| 76 | + <FilterBar | |
| 77 | + action="/models" | |
| 78 | + className="mt-6" | |
| 79 | + resetHref={routes.models()} | |
| 80 | + fields={[ | |
| 81 | + { kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'e.g. claude, llama' }, | |
| 82 | + { kind: 'select', name: 'openness', label: 'Openness', value: current.openness, options: Object.entries(OPENNESS_LABELS).map(([value, label]) => ({ value, label })) }, | |
| 83 | + { kind: 'select', name: 'status', label: 'Status', value: current.status, options: Object.entries(STATUS_LABELS).filter(([v]) => v !== 'unknown').map(([value, label]) => ({ value, label })) }, | |
| 84 | + { kind: 'text', name: 'min_params', label: 'Min params', value: current.min_params, placeholder: 'e.g. 70B' }, | |
| 85 | + { kind: 'text', name: 'min_context', label: 'Min context', value: current.min_context, placeholder: 'e.g. 128k' }, | |
| 86 | + ...(current.org ? [{ kind: 'hidden' as const, name: 'org', value: current.org }] : []), | |
| 87 | + ...(current.modality ? [{ kind: 'hidden' as const, name: 'modality', value: current.modality }] : []), | |
| 88 | + ...(current.family ? [{ kind: 'hidden' as const, name: 'family', value: current.family }] : []), | |
| 89 | + ...(current.license ? [{ kind: 'hidden' as const, name: 'license', value: current.license }] : []), | |
| 90 | + ...(current.year_from ? [{ kind: 'hidden' as const, name: 'year_from', value: current.year_from }] : []), | |
| 91 | + ]} | |
| 92 | + sort={{ value: sort, options: SORTS }} | |
| 93 | + /> | |
| 94 | + <ActiveFilters current={current} labels={LABELS} makeHref={(p) => href(p)} className="mt-3" /> | |
| 95 | + </PageHeader> | |
| 96 | + | |
| 97 | + <div className="pb-16"> | |
| 98 | + <ListingLayout facets={<Facets groups={facetGroups} current={current} makeHref={(p) => href(p)} />}> | |
| 99 | + {!page ? ( | |
| 100 | + <Unavailable what="Models" /> | |
| 101 | + ) : ( | |
| 102 | + <> | |
| 103 | + <DataTable caption="Models"> | |
| 104 | + <thead> | |
| 105 | + <tr> | |
| 106 | + <SortTh s="name">Model</SortTh> | |
| 107 | + <Th>Organization</Th> | |
| 108 | + <SortTh s="params" num>Params</SortTh> | |
| 109 | + <SortTh s="context" num>Context</SortTh> | |
| 110 | + <Th>Openness</Th> | |
| 111 | + <Th>License</Th> | |
| 112 | + <SortTh s="release">Released</SortTh> | |
| 113 | + <SortTh s="quality" num>Quality</SortTh> | |
| 114 | + </tr> | |
| 115 | + </thead> | |
| 116 | + <tbody> | |
| 117 | + {page.items.length === 0 && <EmptyRow cols={8}>No models match these filters.</EmptyRow>} | |
| 118 | + {page.items.map((m) => { | |
| 119 | + const a = m.attributes ?? {}; | |
| 120 | + const p = num(a.parameter_count); | |
| 121 | + const ap = num(a.active_parameter_count); | |
| 122 | + return ( | |
| 123 | + <tr key={m.id}> | |
| 124 | + <Td primary> | |
| 125 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 126 | + <EntityLink e={m} /> | |
| 127 | + <StatusBadge status={m.status !== 'active' ? m.status : null} /> | |
| 128 | + </div> | |
| 129 | + {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>} | |
| 130 | + </Td> | |
| 131 | + <Td label="Organization" className="text-ink-2"> | |
| 132 | + {m.organization ? ( | |
| 133 | + <Link href={href({ org: m.organization.slug, offset: undefined })} className="hover:text-accent"> | |
| 134 | + {m.organization.name} | |
| 135 | + </Link> | |
| 136 | + ) : ( | |
| 137 | + '—' | |
| 138 | + )} | |
| 139 | + </Td> | |
| 140 | + <Td num label="Params" className="tnum"> | |
| 141 | + {p === null ? <span className="text-ink-3">—</span> : fmtParams(p)} | |
| 142 | + {ap !== null && ap !== p && <span className="text-xs text-ink-3"> · {fmtParams(ap)} active</span>} | |
| 143 | + </Td> | |
| 144 | + <Td num label="Context" className="tnum">{num(a.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(a.context_length)}</Td> | |
| 145 | + <Td label="Openness">{typeof a.openness === 'string' ? <OpennessBadge openness={a.openness} /> : <span className="text-ink-3">—</span>}</Td> | |
| 146 | + <Td label="License" className="max-w-[10rem] truncate text-ink-2" title={typeof a.license === 'string' ? a.license : undefined}>{typeof a.license === 'string' ? a.license : <span className="text-ink-3">—</span>}</Td> | |
| 147 | + <Td label="Released" className="tnum text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : <span className="text-ink-3">—</span>}</Td> | |
| 148 | + <Td num label="Quality"><QualityMark q={m.quality?.score} /></Td> | |
| 149 | + </tr> | |
| 150 | + ); | |
| 151 | + })} | |
| 152 | + </tbody> | |
| 153 | + </DataTable> | |
| 154 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 155 | + </> | |
| 156 | + )} | |
| 157 | + </ListingLayout> | |
| 158 | + </div> | |
| 159 | + </Container> | |
| 160 | + ); | |
| 161 | +} | |
added
apps/web/src/app/models/[slug]/page.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { permanentRedirect } from 'next/navigation'; | |
| 3 | +import { EntityPage } from '@/components/entity/entity-page'; | |
| 4 | +import { entityMetadata, loadEntity } from '@/components/entity/load'; | |
| 5 | +import { api, safe } from '@/lib/api'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | + | |
| 8 | +type Params = { params: Promise<{ slug: string }> }; | |
| 9 | + | |
| 10 | +export async function generateMetadata({ params }: Params): Promise<Metadata> { | |
| 11 | + const { slug } = await params; | |
| 12 | + return entityMetadata('models', slug); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export default async function ModelPage({ params }: Params) { | |
| 16 | + const { slug } = await params; | |
| 17 | + const d = await loadEntity('models', slug); | |
| 18 | + if (d.slug !== slug) permanentRedirect(routes.entity(d)); | |
| 19 | + const related = await safe(api.entityRelated(d.slug, 10)); | |
| 20 | + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; | |
| 21 | +} | |
added
apps/web/src/app/not-found.tsx
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Container } from '@/components/ui/section'; | |
| 4 | +import { routes } from '@/lib/site'; | |
| 5 | + | |
| 6 | +export const metadata: Metadata = { title: 'Not found', robots: { index: false } }; | |
| 7 | + | |
| 8 | +export default function NotFound() { | |
| 9 | + return ( | |
| 10 | + <Container className="py-20 md:py-28"> | |
| 11 | + <p className="eyebrow">404</p> | |
| 12 | + <h1 className="display mt-2 text-3xl md:text-5xl">This page is not on the map.</h1> | |
| 13 | + <p className="mt-4 max-w-xl text-ink-2">The entity may have been merged into another record, renamed, or never existed. Search is the fastest way back.</p> | |
| 14 | + <div className="mt-6 flex flex-wrap gap-3 text-sm"> | |
| 15 | + <Link href={routes.home()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Home</Link> | |
| 16 | + <Link href={routes.models()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Models</Link> | |
| 17 | + <Link href={routes.explore()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Explore</Link> | |
| 18 | + <Link href={routes.changes()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Changes</Link> | |
| 19 | + </div> | |
| 20 | + </Container> | |
| 21 | + ); | |
| 22 | +} | |
added
apps/web/src/app/opengraph-image.tsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +import { ImageResponse } from 'next/og'; | |
| 2 | +import { api, safe } from '@/lib/api'; | |
| 3 | +import { fmtInt, num } from '@/lib/format'; | |
| 4 | +import { SITE_NAME, TAGLINE } from '@/lib/site'; | |
| 5 | + | |
| 6 | +export const runtime = 'nodejs'; | |
| 7 | +export const alt = `${SITE_NAME} — ${TAGLINE}`; | |
| 8 | +export const size = { width: 1200, height: 630 }; | |
| 9 | +export const contentType = 'image/png'; | |
| 10 | + | |
| 11 | +/** Default Open Graph image: brand + live counters from /stats when the API is reachable (brand only otherwise). */ | |
| 12 | +export default async function OpenGraphImage() { | |
| 13 | + const stats = await safe(api.stats()); | |
| 14 | + const counters: [string, string][] = stats | |
| 15 | + ? [ | |
| 16 | + ['Models', fmtInt(stats.entities?.model)], | |
| 17 | + ['Companies', fmtInt(num(stats.entities?.company) ?? num(stats.entities?.organization))], | |
| 18 | + ['Papers', fmtInt(stats.entities?.paper)], | |
| 19 | + ['Change events', fmtInt(stats.change_events)], | |
| 20 | + ['Sources', fmtInt(stats.sources)], | |
| 21 | + ].filter((c) => c[1] !== '—') as [string, string][] | |
| 22 | + : []; | |
| 23 | + | |
| 24 | + return new ImageResponse( | |
| 25 | + ( | |
| 26 | + <div | |
| 27 | + style={{ | |
| 28 | + width: '100%', | |
| 29 | + height: '100%', | |
| 30 | + display: 'flex', | |
| 31 | + flexDirection: 'column', | |
| 32 | + justifyContent: 'space-between', | |
| 33 | + padding: '64px 72px', | |
| 34 | + background: 'linear-gradient(160deg, #12151b 0%, #0b0d11 55%, #0b0d11 100%)', | |
| 35 | + color: '#e9ebf0', | |
| 36 | + fontFamily: 'sans-serif', | |
| 37 | + position: 'relative', | |
| 38 | + }} | |
| 39 | + > | |
| 40 | + <div style={{ position: 'absolute', right: -140, top: -160, width: 640, height: 640, borderRadius: 999, background: 'radial-gradient(circle at 40% 40%, rgba(109,149,255,0.18), rgba(11,13,17,0) 70%)', display: 'flex' }} /> | |
| 41 | + <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}> | |
| 42 | + <svg width="64" height="64" viewBox="0 0 32 32" fill="none"> | |
| 43 | + <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" /> | |
| 44 | + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" /> | |
| 45 | + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" /> | |
| 46 | + <circle cx="16" cy="16" r="2.4" fill="#6d95ff" /> | |
| 47 | + <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" /> | |
| 48 | + <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" /> | |
| 49 | + <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" /> | |
| 50 | + <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" /> | |
| 51 | + </svg> | |
| 52 | + <div style={{ display: 'flex', fontSize: 38, fontWeight: 600, letterSpacing: -1 }}> | |
| 53 | + <span>AI</span> | |
| 54 | + <span style={{ color: '#a3a9b8', marginLeft: 10, fontWeight: 500 }}>Atlas</span> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + <div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}> | |
| 58 | + <div style={{ fontSize: 68, fontWeight: 600, lineHeight: 1.02, letterSpacing: -2.5, maxWidth: 940, display: 'flex' }}>{TAGLINE}.</div> | |
| 59 | + <div style={{ fontSize: 25, color: '#a3a9b8', display: 'flex' }}>Models · companies · research · providers & pricing · benchmarks · hardware — with provenance and history</div> | |
| 60 | + </div> | |
| 61 | + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: '#6f7688', fontSize: 22 }}> | |
| 62 | + <span>www.ai-atlas.co</span> | |
| 63 | + {counters.length > 0 && ( | |
| 64 | + <div style={{ display: 'flex', gap: 40 }}> | |
| 65 | + {counters.map(([label, value]) => ( | |
| 66 | + <div key={label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> | |
| 67 | + <span style={{ fontSize: 34, fontWeight: 600, color: '#e9ebf0', letterSpacing: -1 }}>{value}</span> | |
| 68 | + <span style={{ fontSize: 15, letterSpacing: 2, textTransform: 'uppercase' }}>{label}</span> | |
| 69 | + </div> | |
| 70 | + ))} | |
| 71 | + </div> | |
| 72 | + )} | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + ), | |
| 76 | + { ...size }, | |
| 77 | + ); | |
| 78 | +} | |
added
apps/web/src/app/page.tsx
+202 −0
@@ -0,0 +1,202 @@ | ||
| 1 | +import { ArrowRight, Search } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 5 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 6 | +import { EntityLink } from '@/components/ui/entity'; | |
| 7 | +import { LiveAgo } from '@/components/ui/live'; | |
| 8 | +import { Container, Section, Stat, StatGrid } from '@/components/ui/section'; | |
| 9 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 10 | +import { api, safe } from '@/lib/api'; | |
| 11 | +import { fmtDate, fmtInt, num } from '@/lib/format'; | |
| 12 | +import { DESCRIPTION, EXAMPLE_QUERIES, routes, SITE_NAME, SITE_URL, TAGLINE, typeLabel } from '@/lib/site'; | |
| 13 | +import type { ChangeEvent, EntitySummary } from '@/lib/types'; | |
| 14 | + | |
| 15 | +export const metadata: Metadata = { | |
| 16 | + title: { absolute: `${SITE_NAME} — ${TAGLINE}` }, | |
| 17 | + description: DESCRIPTION, | |
| 18 | + alternates: { canonical: '/' }, | |
| 19 | +}; | |
| 20 | +export const revalidate = 60; | |
| 21 | + | |
| 22 | +const HOW = [ | |
| 23 | + { step: 'Connectors', text: 'First-party crawlers read official docs, pricing pages, model cards, papers, feeds and repositories directly — no third-party data APIs.' }, | |
| 24 | + { step: 'Archive', text: 'Every document is snapshotted and kept forever, content-addressed, so any fact can be traced back to the exact page that stated it.' }, | |
| 25 | + { step: 'Graph', text: 'Deterministic extractors (then a local LLM only where needed) turn snapshots into temporal claims and relations with source, tier and confidence.' }, | |
| 26 | + { step: 'History', text: 'Material changes become events: new models, price moves, context changes, deprecations. Nothing is overwritten; conflicts are flagged, never averaged.' }, | |
| 27 | +]; | |
| 28 | + | |
| 29 | +function pickFeed(daily: Awaited<ReturnType<typeof api.changesDaily>> | null, changes: ChangeEvent[] | null): { title: string; items: ChangeEvent[] }[] { | |
| 30 | + const out: { title: string; items: ChangeEvent[] }[] = []; | |
| 31 | + const all = changes ?? []; | |
| 32 | + const by = (pred: (e: ChangeEvent) => boolean, title: string, n = 6) => { | |
| 33 | + const items = all.filter(pred).slice(0, n); | |
| 34 | + if (items.length) out.push({ title, items }); | |
| 35 | + }; | |
| 36 | + by((e) => e.event_type === 'NEW_MODEL', 'New models'); | |
| 37 | + by((e) => e.event_type === 'PRICE_CHANGED' || e.event_type === 'PROVIDER_LISTED', 'Prices'); | |
| 38 | + by((e) => e.event_type.startsWith('BENCHMARK'), 'Benchmarks'); | |
| 39 | + by((e) => e.event_type === 'ANNOUNCEMENT' || e.event_type === 'RELEASE' || e.event_type === 'VERSION_RELEASED', 'Announcements & releases'); | |
| 40 | + by((e) => ['DEPRECATION_ANNOUNCED', 'RETIREMENT_ANNOUNCED', 'STATUS_CHANGED', 'CONTEXT_CHANGED', 'LICENSE_CHANGED', 'OPENNESS_CHANGED'].includes(e.event_type), 'Status, context & licenses'); | |
| 41 | + if (!out.length && daily?.sections?.length) for (const s of daily.sections) if (s.items.length) out.push({ title: s.label, items: s.items.slice(0, 6) }); | |
| 42 | + if (!out.length && all.length) out.push({ title: 'Latest', items: all.slice(0, 12) }); | |
| 43 | + return out; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export default async function HomePage() { | |
| 47 | + const [stats, daily, changes, types, trending] = await Promise.all([safe(api.stats()), safe(api.changesDaily()), safe(api.changes({ importance_min: 2, limit: 40 })), safe(api.exploreTypes()), safe(api.trending(7, 12))]); | |
| 48 | + const feed = pickFeed(daily, changes?.items ?? null); | |
| 49 | + const today = daily?.date ?? new Date().toISOString().slice(0, 10); | |
| 50 | + const ent = stats?.entities ?? {}; | |
| 51 | + const companies = (num(ent.company) ?? 0) + (num(ent.organization) ?? 0) + (num(ent.lab) ?? 0) || null; | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <> | |
| 55 | + {/* ------------------------------------------------------------------------------------------------ hero */} | |
| 56 | + <div className="relative overflow-hidden border-b border-rule"> | |
| 57 | + <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden /> | |
| 58 | + <Container className="relative py-12 md:py-20"> | |
| 59 | + <p className="eyebrow">The AI ecosystem, mapped · with provenance and history</p> | |
| 60 | + <h1 className="display mt-3 max-w-4xl text-[38px] md:text-[64px]">Explore the entire AI ecosystem.</h1> | |
| 61 | + <p className="mt-4 max-w-2xl text-base leading-relaxed text-ink-2 md:text-lg"> | |
| 62 | + Models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — every fact linked to its source, every change kept. | |
| 63 | + </p> | |
| 64 | + <form action="/search" method="get" role="search" className="mt-7 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 65 | + <label htmlFor="home-q" className="sr-only">Search</label> | |
| 66 | + <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span> | |
| 67 | + <input id="home-q" name="q" type="search" placeholder="Ask anything: “open models over 100B released in 2026”, “cheapest 1M context”…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none md:h-14" autoComplete="off" /> | |
| 68 | + <button type="submit" className="flex items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 md:px-5"> | |
| 69 | + Search <ArrowRight className="hidden size-4 md:block" aria-hidden /> | |
| 70 | + </button> | |
| 71 | + </form> | |
| 72 | + <ul className="mt-3 flex flex-wrap gap-2"> | |
| 73 | + {EXAMPLE_QUERIES.map((q) => ( | |
| 74 | + <li key={q}> | |
| 75 | + <Link href={routes.search(q)} className="inline-block border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 76 | + {q} | |
| 77 | + </Link> | |
| 78 | + </li> | |
| 79 | + ))} | |
| 80 | + </ul> | |
| 81 | + </Container> | |
| 82 | + </div> | |
| 83 | + | |
| 84 | + <Container> | |
| 85 | + {/* ------------------------------------------------------------------------------------------------ counters */} | |
| 86 | + <Section eyebrow="Live counters" title={<span className="flex flex-wrap items-center gap-x-3 gap-y-1">Inside the atlas {stats && <span className="flex items-center gap-1.5 text-xs font-normal text-ink-3"><span className="dot pulse" aria-hidden /> updated <LiveAgo at={stats.computed_at} /></span>}</span>} action={{ href: routes.methodology(), label: 'How counts are computed' }} hairline={false}> | |
| 87 | + {stats ? ( | |
| 88 | + <StatGrid cols={8}> | |
| 89 | + <Stat label="Models" value={fmtInt(ent.model)} href={routes.models()} /> | |
| 90 | + <Stat label="Companies" value={fmtInt(companies)} href={routes.companies()} /> | |
| 91 | + <Stat label="Papers" value={fmtInt(ent.paper)} href={routes.papers()} /> | |
| 92 | + <Stat label="Providers" value={fmtInt(ent.provider)} href={routes.providers()} /> | |
| 93 | + <Stat label="Benchmarks" value={fmtInt(ent.benchmark)} href={routes.benchmarks()} /> | |
| 94 | + <Stat label="Hardware" value={fmtInt(ent.hardware)} href={routes.hardware()} /> | |
| 95 | + <Stat label="Change events" value={fmtInt(stats.change_events)} delta={num(stats.change_events_24h) ? { value: `+${fmtInt(stats.change_events_24h)} · 24 h`, tone: 'neutral' } : undefined} href={routes.changes()} /> | |
| 96 | + <Stat label="Sources" value={fmtInt(stats.sources)} hint={`${fmtInt(stats.documents)} documents`} href={routes.sources()} /> | |
| 97 | + </StatGrid> | |
| 98 | + ) : ( | |
| 99 | + <Unavailable what="Live counters" reason="The API did not answer. Counters are never cached or hardcoded." /> | |
| 100 | + )} | |
| 101 | + </Section> | |
| 102 | + | |
| 103 | + {/* ------------------------------------------------------------------------------------------------ today */} | |
| 104 | + <Section eyebrow={`Today in AI · ${fmtDate(today)}`} title="What changed" action={{ href: routes.changesDay(today), label: 'Daily digest' }} lede={daily ? `${fmtInt(Object.values(daily.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0))} events recorded today across ${Object.keys(daily.counts ?? {}).length} categories.` : undefined}> | |
| 105 | + {feed.length ? ( | |
| 106 | + <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2"> | |
| 107 | + {feed.map((f) => ( | |
| 108 | + <div key={f.title}> | |
| 109 | + <p className="eyebrow mb-1">{f.title}</p> | |
| 110 | + <ul className="border-t border-rule"> | |
| 111 | + {f.items.map((e) => ( | |
| 112 | + <ChangeRow key={e.id} e={e} dense /> | |
| 113 | + ))} | |
| 114 | + </ul> | |
| 115 | + </div> | |
| 116 | + ))} | |
| 117 | + </div> | |
| 118 | + ) : changes ? ( | |
| 119 | + <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No important events yet. The change engine only emits events when a source states a material change.</p> | |
| 120 | + ) : ( | |
| 121 | + <Unavailable what="Change feed" /> | |
| 122 | + )} | |
| 123 | + {daily?.new_models && daily.new_models.length > 0 && ( | |
| 124 | + <div className="mt-8"> | |
| 125 | + <p className="eyebrow mb-2">New models today <span className="tnum text-ink-3">{daily.new_models.length}</span></p> | |
| 126 | + <ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm"> | |
| 127 | + {daily.new_models.slice(0, 16).map((m) => ( | |
| 128 | + <li key={m.id}> | |
| 129 | + <EntityLink e={m} className="font-medium" /> | |
| 130 | + {m.organization && <span className="text-ink-3"> · {m.organization.name}</span>} | |
| 131 | + </li> | |
| 132 | + ))} | |
| 133 | + </ul> | |
| 134 | + </div> | |
| 135 | + )} | |
| 136 | + <p className="mt-6 text-sm"> | |
| 137 | + <Link href={routes.changes()} className="link">All changes, filterable by category and importance →</Link> | |
| 138 | + </p> | |
| 139 | + </Section> | |
| 140 | + | |
| 141 | + {/* ------------------------------------------------------------------------------------------------ explore */} | |
| 142 | + <Section eyebrow="Explore" title="Every entity type, with live counts" action={{ href: routes.explore(), label: 'All types' }}> | |
| 143 | + {types?.items?.length ? ( | |
| 144 | + <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-5"> | |
| 145 | + {types.items | |
| 146 | + .filter((t) => (num(t.count) ?? 0) > 0) | |
| 147 | + .sort((a, b) => (num(b.count) ?? 0) - (num(a.count) ?? 0)) | |
| 148 | + .slice(0, 15) | |
| 149 | + .map((t) => ( | |
| 150 | + <li key={t.entity_type} className="border-b border-r border-rule"> | |
| 151 | + <Link href={routes.listing(t.entity_type)} className="block px-4 py-4 hover:bg-surface-2"> | |
| 152 | + <EntityBadge type={t.entity_type} small /> | |
| 153 | + <p className="tnum mt-2 text-2xl font-semibold tracking-tight">{fmtInt(t.count)}</p> | |
| 154 | + <p className="text-sm text-ink-2">{t.label || typeLabel(t.entity_type, true)}</p> | |
| 155 | + </Link> | |
| 156 | + </li> | |
| 157 | + ))} | |
| 158 | + </ul> | |
| 159 | + ) : ( | |
| 160 | + <Unavailable what="Entity types" /> | |
| 161 | + )} | |
| 162 | + </Section> | |
| 163 | + | |
| 164 | + {/* ------------------------------------------------------------------------------------------------ trending */} | |
| 165 | + <Section eyebrow="Trending · 7 days" title="Most viewed" lede="Ranked by page views on AI Atlas, not by any external popularity signal."> | |
| 166 | + {trending?.items?.length ? ( | |
| 167 | + <ol className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 168 | + {trending.items.slice(0, 12).map((t, i) => ( | |
| 169 | + <li key={t.id} className="flex items-baseline gap-3 border-b border-rule py-2 text-sm"> | |
| 170 | + <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span> | |
| 171 | + <EntityBadge type={t.entity_type} small /> | |
| 172 | + <EntityLink e={t as EntitySummary} className="min-w-0 flex-1 truncate font-medium" /> | |
| 173 | + <span className="tnum shrink-0 text-xs text-ink-3">{fmtInt(t.views)} views</span> | |
| 174 | + </li> | |
| 175 | + ))} | |
| 176 | + </ol> | |
| 177 | + ) : trending ? ( | |
| 178 | + <p className="text-sm text-ink-3">Not enough page views yet to rank entities.</p> | |
| 179 | + ) : ( | |
| 180 | + <Unavailable what="Trending" compact /> | |
| 181 | + )} | |
| 182 | + </Section> | |
| 183 | + | |
| 184 | + {/* ------------------------------------------------------------------------------------------------ how */} | |
| 185 | + <Section eyebrow="How AI Atlas works" title="Connectors → archive → graph → history" action={{ href: routes.methodology(), label: 'Methodology' }}> | |
| 186 | + <ol className="grid gap-6 md:grid-cols-4"> | |
| 187 | + {HOW.map((h, i) => ( | |
| 188 | + <li key={h.step} className="border-t-2 border-ink pt-3"> | |
| 189 | + <p className="mono text-xs text-ink-3">0{i + 1}</p> | |
| 190 | + <p className="mt-1 font-semibold">{h.step}</p> | |
| 191 | + <p className="mt-1.5 text-sm leading-relaxed text-ink-2">{h.text}</p> | |
| 192 | + </li> | |
| 193 | + ))} | |
| 194 | + </ol> | |
| 195 | + <p className="mt-6 text-sm text-ink-2"> | |
| 196 | + Public API at <Link href={routes.developers()} className="link mono">{SITE_URL.replace(/^https?:\/\//, '')}/api/v1</Link> · sources listed on <Link href={routes.sources()} className="link">/sources</Link> · crawler policy on <Link href={routes.bot()} className="link">/bot</Link>. | |
| 197 | + </p> | |
| 198 | + </Section> | |
| 199 | + </Container> | |
| 200 | + </> | |
| 201 | + ); | |
| 202 | +} | |
added
apps/web/src/app/papers/page.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | +import { api } from '@/lib/api'; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: 'AI research papers', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe.', alternates: { canonical: '/papers' } }; | |
| 6 | +export const revalidate = 300; | |
| 7 | + | |
| 8 | +export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 9 | + const sp = await searchParams; | |
| 10 | + return ( | |
| 11 | + <GenericListing | |
| 12 | + type="paper" | |
| 13 | + basePath="/papers" | |
| 14 | + eyebrow="Research" | |
| 15 | + title="Papers" | |
| 16 | + lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages." | |
| 17 | + searchParams={sp} | |
| 18 | + fetch={(q) => api.papers(q)} | |
| 19 | + sorts={[{ value: 'published', label: 'Recently published' }, { value: 'updated', label: 'Recently updated' }]} | |
| 20 | + extraFields={[{ kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' }, { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' }]} | |
| 21 | + /> | |
| 22 | + ); | |
| 23 | +} | |
added
apps/web/src/app/providers/page.tsx
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 4 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 5 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 6 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 7 | +import { api, safe } from '@/lib/api'; | |
| 8 | +import { fmtAgo, fmtInt, fmtUsdPerM, num } from '@/lib/format'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: 'AI providers & pricing — USD per 1M tokens', description: 'Inference providers and their published prices per 1M tokens, with model coverage and a full price history.', alternates: { canonical: '/providers' } }; | |
| 11 | +export const revalidate = 300; | |
| 12 | + | |
| 13 | +export default async function ProvidersPage() { | |
| 14 | + const res = await safe(api.providers()); | |
| 15 | + const items = (res?.items ?? []).slice().sort((a, b) => (num(b.model_count) ?? 0) - (num(a.model_count) ?? 0)); | |
| 16 | + return ( | |
| 17 | + <Container> | |
| 18 | + <PageHeader eyebrow="Providers & pricing" title="Inference providers" lede="Who serves which model, at what published price. Prices are per 1M tokens as stated on each provider's pricing page; every change is kept." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} providers</p> : undefined}> | |
| 19 | + <p className="mt-4 text-sm text-ink-2"> | |
| 20 | + Per-model prices and history live on each <Link href="/models" className="link">model page</Link> (Providers & Pricing tab) and on each provider page. | |
| 21 | + </p> | |
| 22 | + </PageHeader> | |
| 23 | + <div className="pb-16"> | |
| 24 | + {!res ? ( | |
| 25 | + <Unavailable what="Providers" /> | |
| 26 | + ) : ( | |
| 27 | + <> | |
| 28 | + <DataTable caption="Providers"> | |
| 29 | + <thead> | |
| 30 | + <tr> | |
| 31 | + <Th>Provider</Th> | |
| 32 | + <Th num>Models</Th> | |
| 33 | + <Th num>Price rows</Th> | |
| 34 | + <Th num>Cheapest input / 1M</Th> | |
| 35 | + <Th num>Cheapest output / 1M</Th> | |
| 36 | + <Th>Updated</Th> | |
| 37 | + <Th num>Quality</Th> | |
| 38 | + </tr> | |
| 39 | + </thead> | |
| 40 | + <tbody> | |
| 41 | + {items.length === 0 && <EmptyRow cols={7}>No providers recorded yet.</EmptyRow>} | |
| 42 | + {items.map((p) => ( | |
| 43 | + <tr key={p.id}> | |
| 44 | + <Td primary> | |
| 45 | + <EntityLink e={p} /> | |
| 46 | + {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>} | |
| 47 | + </Td> | |
| 48 | + <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td> | |
| 49 | + <Td num label="Price rows" className="tnum text-ink-2">{fmtInt(p.price_count)}</Td> | |
| 50 | + <Td num label="Cheapest input" className="tnum text-accent-2">{fmtUsdPerM(p.min_input_per_mtok)}</Td> | |
| 51 | + <Td num label="Cheapest output" className="tnum text-accent-2">{fmtUsdPerM(p.min_output_per_mtok)}</Td> | |
| 52 | + <Td label="Updated" className="text-ink-2" title={p.updated_at}>{fmtAgo(p.updated_at)}</Td> | |
| 53 | + <Td num label="Quality"><QualityMark q={p.quality?.score} /></Td> | |
| 54 | + </tr> | |
| 55 | + ))} | |
| 56 | + </tbody> | |
| 57 | + </DataTable> | |
| 58 | + <Note className="mt-3">Cheapest = lowest current input/output price across the models this provider serves. Open each provider for the full table and history.</Note> | |
| 59 | + </> | |
| 60 | + )} | |
| 61 | + </div> | |
| 62 | + </Container> | |
| 63 | + ); | |
| 64 | +} | |
added
apps/web/src/app/robots.ts
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { MetadataRoute } from 'next'; | |
| 2 | +import { SITE_URL } from '@/lib/site'; | |
| 3 | + | |
| 4 | +export default function robots(): MetadataRoute.Robots { | |
| 5 | + return { | |
| 6 | + rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/search?'] }], | |
| 7 | + sitemap: `${SITE_URL}/sitemap.xml`, | |
| 8 | + host: SITE_URL, | |
| 9 | + }; | |
| 10 | +} | |
added
apps/web/src/app/search/page.tsx
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +import { Search } from 'lucide-react'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 5 | +import { EntityRow } from '@/components/ui/entity'; | |
| 6 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 7 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 8 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 10 | +import { fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 11 | +import { EXAMPLE_QUERIES, exploreNav, propertyLabel, routes, typeLabel } from '@/lib/site'; | |
| 12 | +import type { CompiledQuery } from '@/lib/types'; | |
| 13 | + | |
| 14 | +type SP = { q?: string; type?: string; offset?: string }; | |
| 15 | +const LIMIT = 30; | |
| 16 | + | |
| 17 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 18 | + const { q } = await searchParams; | |
| 19 | + return { title: q ? `“${q}” — search` : 'Search', robots: { index: false, follow: true }, alternates: { canonical: '/search' } }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Human rendering of compile_query output: "models · open weights · released ≥ 2026 · > 100B params". */ | |
| 23 | +function understood(c: CompiledQuery | undefined): string[] { | |
| 24 | + if (!c) return []; | |
| 25 | + const parts: string[] = []; | |
| 26 | + const type = (c.entity_type ?? c.type) as string | null | undefined; | |
| 27 | + if (type) parts.push(typeLabel(type, true).toLowerCase()); | |
| 28 | + const f = (c.filters ?? {}) as Record<string, unknown>; | |
| 29 | + const flat: Record<string, unknown> = { ...f }; | |
| 30 | + const SKIP = new Set(['text', 'q', 'entity_type', 'type', 'filters', 'embedding', 'vector', 'limit', 'offset', 'terms', 'tokens', 'semantic', 'residual', 'sort', 'order']); | |
| 31 | + for (const [k, v] of Object.entries(c)) if (!SKIP.has(k) && v !== null && v !== undefined && v !== '' && typeof v !== 'object') flat[k] = v; | |
| 32 | + for (const [k, v] of Object.entries(flat)) { | |
| 33 | + if (SKIP.has(k) || v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) continue; | |
| 34 | + const key = k.replace(/^(min|max)_/, '').replace(/_(min|max)$/, ''); | |
| 35 | + const isMin = k.startsWith('min_') || k.endsWith('_min') || k.endsWith('_from') || k === 'since'; | |
| 36 | + const isMax = k.startsWith('max_') || k.endsWith('_max') || k.endsWith('_to') || k === 'until'; | |
| 37 | + const sign = isMin ? '≥ ' : isMax ? '≤ ' : ''; | |
| 38 | + let val: string; | |
| 39 | + if (/params|parameter/.test(key) && num(v) !== null) val = fmtParams(v); | |
| 40 | + else if (/context|tokens/.test(key) && num(v) !== null) val = fmtTokens(v); | |
| 41 | + else if (Array.isArray(v)) val = v.map(String).join(', '); | |
| 42 | + else if (typeof v === 'boolean') val = v ? 'yes' : 'no'; | |
| 43 | + else val = String(v); | |
| 44 | + const label = /^(year|release_year)/.test(key) ? 'released' : propertyLabel(key.replace(/_from$|_to$/, '')).toLowerCase(); | |
| 45 | + parts.push(`${label} ${sign}${val}`.trim()); | |
| 46 | + } | |
| 47 | + return parts; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 51 | + const sp = await searchParams; | |
| 52 | + const q = (sp.q ?? '').trim(); | |
| 53 | + const type = sp.type ?? ''; | |
| 54 | + const offset = Math.max(0, Number(sp.offset) || 0); | |
| 55 | + const res = q ? await safe(api.search(q, { type: type || undefined, limit: LIMIT, offset })) : null; | |
| 56 | + const chips = understood(res?.query); | |
| 57 | + const current = { q, type }; | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <Container> | |
| 61 | + <PageHeader eyebrow="Search" title={q ? <>Results for <span className="text-ink-2">“{q}”</span></> : 'Search the atlas'} lede={!q ? 'Names, ids, providers, benchmarks — or a plain-English question. Filters are compiled from your words and shown back to you.' : undefined}> | |
| 62 | + <form action="/search" method="get" role="search" className="mt-5 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> | |
| 63 | + <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span> | |
| 64 | + <input name="q" type="search" defaultValue={q} placeholder="Search models, companies, papers, benchmarks…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" aria-label="Search query" /> | |
| 65 | + {type && <input type="hidden" name="type" value={type} />} | |
| 66 | + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Search</button> | |
| 67 | + </form> | |
| 68 | + {q && ( | |
| 69 | + <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"> | |
| 70 | + <p className="text-ink-3"> | |
| 71 | + Understood as: {chips.length ? <span className="text-ink-2">{chips.join(' · ')}</span> : <span className="text-ink-2">full-text “{q}”</span>} | |
| 72 | + </p> | |
| 73 | + </div> | |
| 74 | + )} | |
| 75 | + {q && ( | |
| 76 | + <ul className="no-scrollbar -mx-4 mt-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0" aria-label="Filter by type"> | |
| 77 | + {[{ label: 'All', type: '' }, ...exploreNav.map((n) => ({ label: n.label.replace(' & Pricing', ''), type: n.type }))].map((t) => { | |
| 78 | + const on = t.type === type; | |
| 79 | + return ( | |
| 80 | + <li key={t.type} className="shrink-0"> | |
| 81 | + <Link href={withParams('/search', current, { type: t.type || undefined, offset: undefined })} className={`inline-flex h-9 items-center border px-3 text-sm ${on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink'}`} aria-current={on ? 'true' : undefined}> | |
| 82 | + {t.label} | |
| 83 | + </Link> | |
| 84 | + </li> | |
| 85 | + ); | |
| 86 | + })} | |
| 87 | + </ul> | |
| 88 | + )} | |
| 89 | + </PageHeader> | |
| 90 | + | |
| 91 | + {!q ? ( | |
| 92 | + <div className="pb-16"> | |
| 93 | + <p className="eyebrow mb-2">Try</p> | |
| 94 | + <ul className="flex flex-wrap gap-2"> | |
| 95 | + {EXAMPLE_QUERIES.map((ex) => ( | |
| 96 | + <li key={ex}> | |
| 97 | + <Link href={routes.search(ex)} className="inline-block border border-rule px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">{ex}</Link> | |
| 98 | + </li> | |
| 99 | + ))} | |
| 100 | + </ul> | |
| 101 | + </div> | |
| 102 | + ) : !res ? ( | |
| 103 | + <Unavailable what="Search" className="mb-16" reason="The search service did not answer." /> | |
| 104 | + ) : res.items.length === 0 ? ( | |
| 105 | + <EmptyState title={`Nothing matched “${q}”`} className="mb-16"> | |
| 106 | + Try fewer words, a provider's API id, or browse{' '} | |
| 107 | + <Link href={routes.explore()} className="link">by type</Link>. Suggestions:{' '} | |
| 108 | + {EXAMPLE_QUERIES.slice(0, 3).map((ex, i) => ( | |
| 109 | + <span key={ex}> | |
| 110 | + {i > 0 && ' · '} | |
| 111 | + <Link href={routes.search(ex)} className="link">{ex}</Link> | |
| 112 | + </span> | |
| 113 | + ))} | |
| 114 | + </EmptyState> | |
| 115 | + ) : ( | |
| 116 | + <div className="pb-16"> | |
| 117 | + <p className="tnum mb-2 text-xs text-ink-3">{fmtInt(res.total)} result{res.total === 1 ? '' : 's'}</p> | |
| 118 | + <ul className="border-t border-rule"> | |
| 119 | + {res.items.map((e) => ( | |
| 120 | + <EntityRow key={e.id} e={e} trailing={<EntityBadge type={e.entity_type} small className="md:hidden" />} /> | |
| 121 | + ))} | |
| 122 | + </ul> | |
| 123 | + <Pagination total={res.total} limit={LIMIT} offset={offset} makeHref={(o) => withParams('/search', current, { offset: o || undefined })} className="mt-4" /> | |
| 124 | + </div> | |
| 125 | + )} | |
| 126 | + </Container> | |
| 127 | + ); | |
| 128 | +} | |
added
apps/web/src/app/sitemap.xml/route.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +import { SITEMAP_HEADERS, shardIds, toIndex } from '@/components/meta/sitemap-data'; | |
| 2 | + | |
| 3 | +/** Sitemap index at /sitemap.xml: one entry per shard served by /sitemap/<id>.xml. Hourly revalidation. */ | |
| 4 | +export const revalidate = 3600; | |
| 5 | + | |
| 6 | +export async function GET(): Promise<Response> { | |
| 7 | + const ids = await shardIds(); | |
| 8 | + return new Response(toIndex(ids.map((id) => `/sitemap/${id}.xml`)), { headers: SITEMAP_HEADERS }); | |
| 9 | +} | |
added
apps/web/src/app/sitemap/[shard]/route.ts
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +import { SITEMAP_HEADERS, shardEntries, toUrlset } from '@/components/meta/sitemap-data'; | |
| 2 | + | |
| 3 | +/** Sitemap shards: /sitemap/static.xml, /sitemap/model-0.xml … (5 000 URLs per shard, from GET /sitemap?type=&offset=). */ | |
| 4 | +export const revalidate = 3600; | |
| 5 | + | |
| 6 | +export async function GET(_req: Request, ctx: { params: Promise<{ shard: string }> }): Promise<Response> { | |
| 7 | + const { shard } = await ctx.params; | |
| 8 | + const m = /^([a-z_]+(?:-\d+)?)\.xml$/.exec(shard); | |
| 9 | + if (!m) return new Response('Not Found', { status: 404 }); | |
| 10 | + const entries = await shardEntries(m[1] as string); | |
| 11 | + if (entries === null) return new Response('Not Found', { status: 404 }); | |
| 12 | + return new Response(toUrlset(entries), { headers: SITEMAP_HEADERS }); | |
| 13 | +} | |
added
apps/web/src/app/sources/page.tsx
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { TierBadge } from '@/components/ui/badges'; | |
| 4 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 5 | +import { Container, Note, PageHeader, Stat, StatGrid } from '@/components/ui/section'; | |
| 6 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 7 | +import { api, safe } from '@/lib/api'; | |
| 8 | +import { cn } from '@/lib/cn'; | |
| 9 | +import { fmtAgo, fmtDuration, fmtInt, num, titleCase } from '@/lib/format'; | |
| 10 | +import { routes } from '@/lib/site'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { title: 'Sources — every site AI Atlas reads, with tier and connector health', description: 'Transparency page: the sources AI Atlas crawls, their tier, document counts, last crawl and connector health.', alternates: { canonical: '/sources' } }; | |
| 13 | +export const revalidate = 300; | |
| 14 | + | |
| 15 | +const HEALTH: Record<string, string> = { ok: 'text-positive', healthy: 'text-positive', degraded: 'text-warning', failing: 'text-danger', broken: 'text-danger', disabled: 'text-ink-3', unknown: 'text-ink-3' }; | |
| 16 | + | |
| 17 | +export default async function SourcesPage() { | |
| 18 | + const res = await safe(api.sources()); | |
| 19 | + const items = (res?.items ?? []).slice().sort((a, b) => a.tier - b.tier || (num(b.documents) ?? 0) - (num(a.documents) ?? 0)); | |
| 20 | + const connectors = items.flatMap((s) => s.connectors ?? []); | |
| 21 | + const health = (h: string) => connectors.filter((c) => c.health === h).length; | |
| 22 | + const docs = items.reduce((n, s) => n + (num(s.documents) ?? 0), 0); | |
| 23 | + return ( | |
| 24 | + <Container wide> | |
| 25 | + <PageHeader eyebrow="Sources" title="What AI Atlas reads" lede="Every source is crawled directly and archived. Tier grades reliability (1 official → 4 unverified); connector health is live." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} sources · {fmtInt(connectors.length)} connectors</p> : undefined} /> | |
| 26 | + <div className="pb-16"> | |
| 27 | + {!res ? ( | |
| 28 | + <Unavailable what="Sources" /> | |
| 29 | + ) : ( | |
| 30 | + <> | |
| 31 | + <StatGrid cols={5} className="mb-8"> | |
| 32 | + <Stat label="Sources" value={fmtInt(items.length)} /> | |
| 33 | + <Stat label="Documents" value={fmtInt(docs)} /> | |
| 34 | + <Stat label="Connectors OK" value={fmtInt(health('ok') + health('healthy'))} /> | |
| 35 | + <Stat label="Degraded" value={fmtInt(health('degraded'))} accent={health('degraded') > 0} /> | |
| 36 | + <Stat label="Failing" value={fmtInt(health('failing') + health('broken'))} /> | |
| 37 | + </StatGrid> | |
| 38 | + <DataTable caption="Sources"> | |
| 39 | + <thead> | |
| 40 | + <tr> | |
| 41 | + <Th>Source</Th> | |
| 42 | + <Th>Tier</Th> | |
| 43 | + <Th>Kind</Th> | |
| 44 | + <Th>Category</Th> | |
| 45 | + <Th num>Documents</Th> | |
| 46 | + <Th>Last crawled</Th> | |
| 47 | + <Th>Connectors</Th> | |
| 48 | + </tr> | |
| 49 | + </thead> | |
| 50 | + <tbody> | |
| 51 | + {items.length === 0 && <EmptyRow cols={7}>No sources registered.</EmptyRow>} | |
| 52 | + {items.map((s) => ( | |
| 53 | + <tr key={s.key} className={!s.enabled ? 'opacity-60' : undefined}> | |
| 54 | + <Td primary> | |
| 55 | + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer" className="hover:text-accent">{s.name}</a> | |
| 56 | + <span className="mono block text-[11px] text-ink-3">{s.domain}{s.organization ? ` · ${s.organization.name}` : ''}</span> | |
| 57 | + </Td> | |
| 58 | + <Td label="Tier"><TierBadge tier={s.tier} withLabel /></Td> | |
| 59 | + <Td label="Kind" className="text-ink-2">{titleCase(s.kind)}</Td> | |
| 60 | + <Td label="Category" className="text-ink-2">{titleCase(s.category)}</Td> | |
| 61 | + <Td num label="Documents" className="tnum">{fmtInt(s.documents)}</Td> | |
| 62 | + <Td label="Last crawled" className="text-ink-2" title={s.last_crawled_at ?? undefined}>{fmtAgo(s.last_crawled_at)}</Td> | |
| 63 | + <Td label="Connectors" wide> | |
| 64 | + {s.connectors?.length ? ( | |
| 65 | + <ul className="space-y-0.5 text-xs"> | |
| 66 | + {s.connectors.map((c) => ( | |
| 67 | + <li key={c.name} className="flex flex-wrap items-center gap-x-2"> | |
| 68 | + <span className={cn('inline-block size-1.5 rounded-full', c.health === 'ok' || c.health === 'healthy' ? 'bg-positive' : c.health === 'degraded' ? 'bg-warning' : c.health === 'disabled' ? 'bg-ink-3' : 'bg-danger')} aria-hidden /> | |
| 69 | + <span className="mono text-ink-2">{c.name}</span> | |
| 70 | + <span className={HEALTH[c.health] ?? 'text-ink-3'}>{c.health}</span> | |
| 71 | + <span className="text-ink-3">every {fmtDuration(c.interval_seconds)} · last success {fmtAgo(c.last_success_at)}</span> | |
| 72 | + </li> | |
| 73 | + ))} | |
| 74 | + </ul> | |
| 75 | + ) : ( | |
| 76 | + <span className="text-ink-3">—</span> | |
| 77 | + )} | |
| 78 | + </Td> | |
| 79 | + </tr> | |
| 80 | + ))} | |
| 81 | + </tbody> | |
| 82 | + </DataTable> | |
| 83 | + <Note className="mt-4"> | |
| 84 | + Crawling policy: robots.txt honoured, per-domain rate limits, conditional requests, identified user agent (<Link href={routes.bot()} className="link mono">AIAtlasBot</Link>). Tiers and confidence are explained in the <Link href={routes.methodology()} className="link">methodology</Link>. | |
| 85 | + </Note> | |
| 86 | + </> | |
| 87 | + )} | |
| 88 | + </div> | |
| 89 | + </Container> | |
| 90 | + ); | |
| 91 | +} | |
added
apps/web/src/app/timeline/page.tsx
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | +import { FilterBar } from '@/components/listing/filters'; | |
| 5 | +import { withParams } from '@/components/ui/pagination'; | |
| 6 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 7 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 8 | +import { api, safe } from '@/lib/api'; | |
| 9 | +import { fmtInt, fmtMonth } from '@/lib/format'; | |
| 10 | +import { CATEGORY_LABELS, routes } from '@/lib/site'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { title: 'Timeline — the AI ecosystem month by month', description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity.', alternates: { canonical: '/timeline' } }; | |
| 13 | +export const revalidate = 600; | |
| 14 | + | |
| 15 | +type SP = Record<string, string | undefined>; | |
| 16 | +const YEARS = Array.from({ length: 8 }, (_, i) => String(new Date().getUTCFullYear() - i)); | |
| 17 | + | |
| 18 | +export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 19 | + const sp = await searchParams; | |
| 20 | + const current: Record<string, string | undefined> = {}; | |
| 21 | + for (const k of ['entity', 'year', 'category']) if (sp[k]) current[k] = sp[k]; | |
| 22 | + const res = await safe(api.timeline({ ...current, limit: 400 })); | |
| 23 | + const months = res?.items ?? []; | |
| 24 | + const total = months.reduce((n, m) => n + m.events.length, 0); | |
| 25 | + const entity = current.entity ? await safe(api.entity(current.entity)) : null; | |
| 26 | + return ( | |
| 27 | + <Container> | |
| 28 | + <PageHeader eyebrow="Timeline" title={entity ? <>Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link></> : 'The ecosystem, month by month'} lede="Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} events shown</p> : undefined}> | |
| 29 | + <FilterBar | |
| 30 | + action="/timeline" | |
| 31 | + className="mt-6 lg:grid-cols-4" | |
| 32 | + resetHref={routes.timeline()} | |
| 33 | + fields={[ | |
| 34 | + { kind: 'text', name: 'entity', label: 'Entity slug', value: current.entity, placeholder: 'e.g. claude-opus-5' }, | |
| 35 | + { kind: 'select', name: 'year', label: 'Year', value: current.year, options: YEARS.map((y) => ({ value: y, label: y })) }, | |
| 36 | + { kind: 'select', name: 'category', label: 'Category', value: current.category, options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })) }, | |
| 37 | + ]} | |
| 38 | + /> | |
| 39 | + </PageHeader> | |
| 40 | + <div className="pb-16"> | |
| 41 | + {!res ? ( | |
| 42 | + <Unavailable what="Timeline" /> | |
| 43 | + ) : months.length === 0 ? ( | |
| 44 | + <EmptyState title="No events for this selection">Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.</EmptyState> | |
| 45 | + ) : ( | |
| 46 | + <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]"> | |
| 47 | + <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0"> | |
| 48 | + {months.map((m) => ( | |
| 49 | + <a key={m.month} href={`#m-${m.month}`} className="tnum flex shrink-0 items-center justify-between gap-2 border border-rule px-2 py-1 text-xs text-ink-2 hover:text-ink lg:border-0 lg:px-1"> | |
| 50 | + <span>{fmtMonth(m.month)}</span> | |
| 51 | + <span className="text-ink-3">{m.events.length}</span> | |
| 52 | + </a> | |
| 53 | + ))} | |
| 54 | + </nav> | |
| 55 | + <div className="min-w-0"> | |
| 56 | + {months.map((m) => ( | |
| 57 | + <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8"> | |
| 58 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 59 | + {fmtMonth(m.month)} <span className="tnum text-ink-3">{m.events.length}</span> | |
| 60 | + </h2> | |
| 61 | + <ul className="border-t border-rule"> | |
| 62 | + {m.events.map((e) => ( | |
| 63 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 64 | + ))} | |
| 65 | + </ul> | |
| 66 | + </section> | |
| 67 | + ))} | |
| 68 | + <p className="text-xs text-ink-3">Showing up to 400 events. Narrow by year, category or entity{current.entity ? '' : `, or use ${withParams('/changes', {}, {})} for the cursor-paged feed`}.</p> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + </div> | |
| 73 | + </Container> | |
| 74 | + ); | |
| 75 | +} | |
added
apps/web/src/app/tools/page.tsx
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { GenericListing } from '@/components/listing/generic-listing'; | |
| 3 | + | |
| 4 | +export const metadata: Metadata = { title: 'AI tools, agents and products', description: 'Developer tools, agents, MCP servers and products in the AI ecosystem, with sources and history.', alternates: { canonical: '/tools' } }; | |
| 5 | +export const revalidate = 300; | |
| 6 | + | |
| 7 | +export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 8 | + const sp = await searchParams; | |
| 9 | + return <GenericListing type="tool" basePath="/tools" eyebrow="Tools" title="Tools, agents & products" lede="Developer tools, agents and MCP servers — as described by their own documentation." searchParams={sp} />; | |
| 10 | +} | |
added
apps/web/src/components/brand/logo.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import { cn } from '@/lib/cn'; | |
| 2 | + | |
| 3 | +/** The AI Atlas mark: a globe with meridians and a small relation graph (5 nodes) — legible at 16 px. Inherits `currentColor`. */ | |
| 4 | +export function LogoMark({ size = 24, className }: { size?: number; className?: string }) { | |
| 5 | + return ( | |
| 6 | + <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn('shrink-0', className)} aria-hidden> | |
| 7 | + <circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="1.6" /> | |
| 8 | + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 9 | + <path d="M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 10 | + <path d="M3 16h26" stroke="currentColor" strokeWidth="1.2" opacity="0.7" /> | |
| 11 | + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="currentColor" strokeWidth="1.2" opacity="0.55" /> | |
| 12 | + <circle cx="16" cy="16" r="2.4" className="fill-accent" /> | |
| 13 | + <circle cx="9.4" cy="9.5" r="1.7" fill="currentColor" /> | |
| 14 | + <circle cx="22.6" cy="9.5" r="1.7" fill="currentColor" /> | |
| 15 | + <circle cx="10.8" cy="24.4" r="1.7" fill="currentColor" /> | |
| 16 | + <circle cx="23.4" cy="21.6" r="1.7" fill="currentColor" /> | |
| 17 | + </svg> | |
| 18 | + ); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function Wordmark({ className, markSize = 22 }: { className?: string; markSize?: number }) { | |
| 22 | + return ( | |
| 23 | + <span className={cn('inline-flex items-center gap-2 text-ink', className)}> | |
| 24 | + <LogoMark size={markSize} /> | |
| 25 | + <span className="text-[17px] font-semibold tracking-tight"> | |
| 26 | + AI <span className="font-medium text-ink-2">Atlas</span> | |
| 27 | + </span> | |
| 28 | + </span> | |
| 29 | + ); | |
| 30 | +} | |
added
apps/web/src/components/changes/change-row.tsx
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { EntityBadge, ImportanceMark } from '@/components/ui/badges'; | |
| 4 | +import { LiveAgo } from '@/components/ui/live'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { fmtDate, fmtDateTime, fmtValue } from '@/lib/format'; | |
| 7 | +import { eventLabel, eventTone, propertyLabel, routes } from '@/lib/site'; | |
| 8 | +import type { ChangeEvent } from '@/lib/types'; | |
| 9 | + | |
| 10 | +const TONE: Record<ReturnType<typeof eventTone>, string> = { | |
| 11 | + new: 'text-positive', | |
| 12 | + price: 'text-accent-2', | |
| 13 | + warn: 'text-warning', | |
| 14 | + danger: 'text-danger', | |
| 15 | + bench: 'text-type-benchmark', | |
| 16 | + neutral: 'text-ink-2', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +function host(url: string): string { | |
| 20 | + try { | |
| 21 | + return new URL(url).hostname.replace(/^www\./, ''); | |
| 22 | + } catch { | |
| 23 | + return 'source'; | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** Old → new rendering for property changes and prices. */ | |
| 28 | +export function Delta({ e }: { e: ChangeEvent }) { | |
| 29 | + if (e.old_value === undefined || e.old_value === null || e.new_value === undefined || e.new_value === null) return null; | |
| 30 | + const key = e.property ?? undefined; | |
| 31 | + return ( | |
| 32 | + <span className="tnum inline-flex flex-wrap items-center gap-1 text-xs"> | |
| 33 | + <span className="text-ink-3 line-through decoration-ink-3/60">{fmtValue(e.old_value, key)}</span> | |
| 34 | + <span className="text-ink-3">→</span> | |
| 35 | + <span className="font-medium text-ink">{fmtValue(e.new_value, key)}</span> | |
| 36 | + </span> | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * One event in a feed: importance meter · time · entity badge + name · summary · property delta · source. | |
| 42 | + * `dense` (homepage) hides the delta and connector; `showDate` prints the date instead of relative time. | |
| 43 | + */ | |
| 44 | +export function ChangeRow({ e, dense = false, showDate = false, className, live = true }: { e: ChangeEvent; dense?: boolean; showDate?: boolean; className?: string; live?: boolean }) { | |
| 45 | + const tone = eventTone(e.event_type); | |
| 46 | + const when = e.effective_at ?? e.observed_at; | |
| 47 | + return ( | |
| 48 | + <li className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 border-b border-rule py-2.5 md:grid-cols-[5.5rem_auto_minmax(0,1fr)_auto] md:items-baseline', className)}> | |
| 49 | + <div className="col-span-2 flex items-center gap-2 text-xs text-ink-3 md:col-span-1 md:block"> | |
| 50 | + {showDate ? <time dateTime={when} title={fmtDateTime(e.observed_at)}>{fmtDate(when)}</time> : live ? <LiveAgo at={e.observed_at} /> : <time dateTime={e.observed_at} title={fmtDateTime(e.observed_at)}>{fmtDate(e.observed_at)}</time>} | |
| 51 | + <ImportanceMark importance={e.importance} className="md:hidden" /> | |
| 52 | + </div> | |
| 53 | + <div className="hidden md:flex md:items-center md:self-center"> | |
| 54 | + <ImportanceMark importance={e.importance} /> | |
| 55 | + </div> | |
| 56 | + <div className="col-span-2 min-w-0 md:col-span-1"> | |
| 57 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 58 | + <span className={cn('text-[11px] font-medium uppercase tracking-wide', TONE[tone])}>{eventLabel(e.event_type)}</span> | |
| 59 | + {e.entity && ( | |
| 60 | + <> | |
| 61 | + <EntityBadge type={e.entity.entity_type} small /> | |
| 62 | + <Link href={routes.entity(e.entity)} className="truncate text-[15px] font-medium text-ink hover:text-accent hover:underline"> | |
| 63 | + {e.entity.name} | |
| 64 | + </Link> | |
| 65 | + {e.entity.organization && <span className="truncate text-xs text-ink-3">{e.entity.organization.name}</span>} | |
| 66 | + </> | |
| 67 | + )} | |
| 68 | + </div> | |
| 69 | + <p className={cn('mt-0.5 text-sm text-ink-2', dense && 'line-clamp-2')}>{e.summary}</p> | |
| 70 | + {!dense && ( | |
| 71 | + <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5"> | |
| 72 | + {e.property && <span className="text-xs text-ink-3">{propertyLabel(e.property)}</span>} | |
| 73 | + <Delta e={e} /> | |
| 74 | + {e.connector_name && <span className="mono text-[11px] text-ink-3">{e.connector_name}</span>} | |
| 75 | + </div> | |
| 76 | + )} | |
| 77 | + </div> | |
| 78 | + <div className="col-span-2 mt-1 text-xs md:col-span-1 md:mt-0 md:text-right"> | |
| 79 | + {e.source_url ? ( | |
| 80 | + <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent"> | |
| 81 | + {host(e.source_url)} <ExternalLink className="size-3" aria-hidden /> | |
| 82 | + </a> | |
| 83 | + ) : ( | |
| 84 | + <span className="text-ink-3">—</span> | |
| 85 | + )} | |
| 86 | + </div> | |
| 87 | + </li> | |
| 88 | + ); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** Group events by UTC day (YYYY-MM-DD of observed_at). */ | |
| 92 | +export function groupByDay(events: ChangeEvent[]): { day: string; items: ChangeEvent[] }[] { | |
| 93 | + const map = new Map<string, ChangeEvent[]>(); | |
| 94 | + for (const e of events) { | |
| 95 | + const day = e.observed_at.slice(0, 10); | |
| 96 | + const arr = map.get(day); | |
| 97 | + if (arr) arr.push(e); | |
| 98 | + else map.set(day, [e]); | |
| 99 | + } | |
| 100 | + return [...map.entries()].map(([day, items]) => ({ day, items })); | |
| 101 | +} | |
added
apps/web/src/components/changes/load-more.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useState } from 'react'; | |
| 3 | +import { clientApi } from '@/lib/client-api'; | |
| 4 | +import { fmtDate } from '@/lib/format'; | |
| 5 | +import type { ChangeEvent } from '@/lib/types'; | |
| 6 | +import { ChangeRow, groupByDay } from './change-row'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Cursor pagination for /changes: the server renders the first page; this appends more via `before=<observed_at>`. | |
| 10 | + * `qs` carries the active filters (category, type, importance_min, since…). | |
| 11 | + */ | |
| 12 | +export function LoadMore({ qs, initialCursor, lastDay }: { qs: string; initialCursor: string | null; lastDay: string | null }) { | |
| 13 | + const [items, setItems] = useState<ChangeEvent[]>([]); | |
| 14 | + const [cursor, setCursor] = useState<string | null>(initialCursor); | |
| 15 | + const [loading, setLoading] = useState(false); | |
| 16 | + const [error, setError] = useState(false); | |
| 17 | + const groups = groupByDay(items); | |
| 18 | + | |
| 19 | + const more = async () => { | |
| 20 | + if (!cursor) return; | |
| 21 | + setLoading(true); | |
| 22 | + setError(false); | |
| 23 | + try { | |
| 24 | + const p = new URLSearchParams(qs); | |
| 25 | + p.set('before', cursor); | |
| 26 | + p.set('limit', '50'); | |
| 27 | + const res = await clientApi.changes(p.toString()); | |
| 28 | + setItems((prev) => [...prev, ...res.items]); | |
| 29 | + const last = res.items[res.items.length - 1]; | |
| 30 | + setCursor(res.items.length < 50 || !last ? null : last.observed_at); | |
| 31 | + } catch { | |
| 32 | + setError(true); | |
| 33 | + } finally { | |
| 34 | + setLoading(false); | |
| 35 | + } | |
| 36 | + }; | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <> | |
| 40 | + {groups.map((g, i) => ( | |
| 41 | + <section key={g.day} className="mt-8"> | |
| 42 | + {(i > 0 || g.day !== lastDay) && ( | |
| 43 | + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> | |
| 44 | + {fmtDate(g.day)} <span className="tnum text-ink-3">{g.items.length}</span> | |
| 45 | + </h2> | |
| 46 | + )} | |
| 47 | + <ul className="border-t border-rule"> | |
| 48 | + {g.items.map((e) => ( | |
| 49 | + <ChangeRow key={e.id} e={e} /> | |
| 50 | + ))} | |
| 51 | + </ul> | |
| 52 | + </section> | |
| 53 | + ))} | |
| 54 | + <div className="mt-6 flex items-center gap-3"> | |
| 55 | + {cursor ? ( | |
| 56 | + <button type="button" onClick={more} disabled={loading} className="h-10 border border-rule px-4 text-sm text-ink-2 hover:border-rule-strong hover:text-ink disabled:opacity-50"> | |
| 57 | + {loading ? 'Loading…' : 'Load older events'} | |
| 58 | + </button> | |
| 59 | + ) : ( | |
| 60 | + <p className="text-xs text-ink-3">End of the recorded history for these filters.</p> | |
| 61 | + )} | |
| 62 | + {error && <p className="text-xs text-danger">Could not load more — try again.</p>} | |
| 63 | + </div> | |
| 64 | + </> | |
| 65 | + ); | |
| 66 | +} | |
added
apps/web/src/components/charts/charts.tsx
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +import { extent, max } from 'd3-array'; | |
| 2 | +import { scaleLinear, scaleTime } from 'd3-scale'; | |
| 3 | +import { area, curveMonotoneX, line } from 'd3-shape'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { fmtCompact } from '@/lib/format'; | |
| 6 | + | |
| 7 | +/* | |
| 8 | + Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they | |
| 9 | + follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%. | |
| 10 | +*/ | |
| 11 | + | |
| 12 | +export type Point = { x: number | Date; y: number }; | |
| 13 | + | |
| 14 | +export function Sparkline({ values, width = 120, height = 28, className, stroke = 'var(--accent)', fill = true, strokeWidth = 1.5 }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; strokeWidth?: number }) { | |
| 15 | + const clean = values.filter((v) => Number.isFinite(v)); | |
| 16 | + if (clean.length < 2) return <span className={cn('inline-block text-xs text-ink-3', className)}>—</span>; | |
| 17 | + const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]); | |
| 18 | + const [lo, hi] = extent(clean) as [number, number]; | |
| 19 | + const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]); | |
| 20 | + const l = line<number>().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX); | |
| 21 | + const a = area<number>().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX); | |
| 22 | + const last = clean[clean.length - 1] as number; | |
| 23 | + return ( | |
| 24 | + <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', className)} aria-hidden> | |
| 25 | + {fill && <path d={a(clean) ?? ''} fill={stroke} opacity={0.1} />} | |
| 26 | + <path d={l(clean) ?? ''} fill="none" stroke={stroke} strokeWidth={strokeWidth} /> | |
| 27 | + <circle cx={x(clean.length - 1)} cy={y(last)} r={2} fill={stroke} /> | |
| 28 | + </svg> | |
| 29 | + ); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */ | |
| 33 | +export function Bars({ data, height = 120, className, color = 'var(--series-1)', showLabels = true, format = fmtCompact }: { data: { label: string; value: number }[]; height?: number; className?: string; color?: string; showLabels?: boolean; format?: (v: number) => string }) { | |
| 34 | + if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>; | |
| 35 | + const w = 600; | |
| 36 | + const pad = { l: 0, r: 0, t: 14, b: showLabels ? 18 : 2 }; | |
| 37 | + const m = max(data, (d) => d.value) ?? 0; | |
| 38 | + const y = scaleLinear().domain([0, m || 1]).range([height - pad.b, pad.t]); | |
| 39 | + const bw = (w - pad.l - pad.r) / data.length; | |
| 40 | + const every = Math.ceil(data.length / 12); | |
| 41 | + return ( | |
| 42 | + <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label="Bar chart"> | |
| 43 | + <line x1={0} x2={w} y1={height - pad.b} y2={height - pad.b} stroke="var(--rule-strong)" /> | |
| 44 | + {data.map((d, i) => { | |
| 45 | + const h = height - pad.b - y(d.value); | |
| 46 | + return ( | |
| 47 | + <g key={d.label + i}> | |
| 48 | + <rect x={pad.l + i * bw + bw * 0.15} y={y(d.value)} width={bw * 0.7} height={Math.max(0, h)} fill={color} opacity={0.9} /> | |
| 49 | + {d.value > 0 && data.length <= 24 && ( | |
| 50 | + <text x={pad.l + i * bw + bw / 2} y={y(d.value) - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="tnum"> | |
| 51 | + {format(d.value)} | |
| 52 | + </text> | |
| 53 | + )} | |
| 54 | + {showLabels && i % every === 0 && ( | |
| 55 | + <text x={pad.l + i * bw + bw / 2} y={height - 5} textAnchor="middle" fontSize={9} fill="var(--ink-3)"> | |
| 56 | + {d.label} | |
| 57 | + </text> | |
| 58 | + )} | |
| 59 | + </g> | |
| 60 | + ); | |
| 61 | + })} | |
| 62 | + </svg> | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** Horizontal bars with labels — for rankings (top orgs, categories). Uses HTML for crisp text. */ | |
| 67 | +export function HBars({ data, className, color = 'var(--series-1)', format = fmtCompact, max: maxOverride, href }: { data: { label: string; value: number; sub?: string; href?: string }[]; className?: string; color?: string; format?: (v: number) => string; max?: number; href?: (d: { label: string }) => string | undefined }) { | |
| 68 | + if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>; | |
| 69 | + const m = (maxOverride ?? max(data, (d) => d.value) ?? 0) || 1; | |
| 70 | + return ( | |
| 71 | + <ol className={cn('space-y-1.5', className)}> | |
| 72 | + {data.map((d) => { | |
| 73 | + const link = d.href ?? href?.(d); | |
| 74 | + const label = link ? ( | |
| 75 | + <a href={link} className="truncate text-ink hover:text-accent hover:underline"> | |
| 76 | + {d.label} | |
| 77 | + </a> | |
| 78 | + ) : ( | |
| 79 | + <span className="truncate text-ink">{d.label}</span> | |
| 80 | + ); | |
| 81 | + return ( | |
| 82 | + <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-sm"> | |
| 83 | + <div className="flex min-w-0 items-baseline gap-2"> | |
| 84 | + {label} | |
| 85 | + {d.sub && <span className="truncate text-xs text-ink-3">{d.sub}</span>} | |
| 86 | + </div> | |
| 87 | + <span className="tnum text-xs text-ink-2">{format(d.value)}</span> | |
| 88 | + <div className="col-span-2 h-1 rounded-sm bg-surface-2"> | |
| 89 | + <div className="h-full rounded-sm" style={{ width: `${(100 * d.value) / m}%`, background: color }} /> | |
| 90 | + </div> | |
| 91 | + </li> | |
| 92 | + ); | |
| 93 | + })} | |
| 94 | + </ol> | |
| 95 | + ); | |
| 96 | +} | |
| 97 | + | |
| 98 | +export type Series = { name: string; color?: string; points: Point[] }; | |
| 99 | + | |
| 100 | +/** Multi-series line chart with time or linear x axis, light grid, end labels. */ | |
| 101 | +export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number] }) { | |
| 102 | + const all = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p.y)); | |
| 103 | + if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>; | |
| 104 | + const w = 720; | |
| 105 | + const pad = { l: 44, r: 16, t: 12, b: 24 }; | |
| 106 | + const xs = all.map((p) => (p.x instanceof Date ? p.x.getTime() : Number(p.x))); | |
| 107 | + const [x0, x1] = extent(xs) as [number, number]; | |
| 108 | + const x = xTime ? scaleTime().domain([new Date(x0), new Date(x1 === x0 ? x0 + 86400000 : x1)]).range([pad.l, w - pad.r]) : scaleLinear().domain([x0, x1 === x0 ? x0 + 1 : x1]).range([pad.l, w - pad.r]); | |
| 109 | + const ys = all.map((p) => p.y); | |
| 110 | + let [y0, y1] = yDomain ?? (extent(ys) as [number, number]); | |
| 111 | + if (y0 === y1) { | |
| 112 | + y0 = y0 * 0.9; | |
| 113 | + y1 = y1 * 1.1 || 1; | |
| 114 | + } | |
| 115 | + if (!yDomain) y0 = Math.min(0, y0); | |
| 116 | + const y = scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); | |
| 117 | + const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number; | |
| 118 | + const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX); | |
| 119 | + const ticks = y.ticks(4); | |
| 120 | + const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5); | |
| 121 | + const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); | |
| 122 | + return ( | |
| 123 | + <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}> | |
| 124 | + {ticks.map((t) => ( | |
| 125 | + <g key={t}> | |
| 126 | + <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" /> | |
| 127 | + <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum"> | |
| 128 | + {yFormat(t)} | |
| 129 | + </text> | |
| 130 | + </g> | |
| 131 | + ))} | |
| 132 | + {xt.map((t, i) => ( | |
| 133 | + <text key={i} x={x(t as never) as number} y={height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)"> | |
| 134 | + {fmtX(t)} | |
| 135 | + </text> | |
| 136 | + ))} | |
| 137 | + {series.map((s, i) => { | |
| 138 | + const pts = s.points.filter((p) => Number.isFinite(p.y)).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x))); | |
| 139 | + if (pts.length === 0) return null; | |
| 140 | + const color = s.color ?? `var(--series-${(i % 8) + 1})`; | |
| 141 | + const last = pts[pts.length - 1] as Point; | |
| 142 | + return ( | |
| 143 | + <g key={s.name}> | |
| 144 | + <path d={l(pts) ?? ''} fill="none" stroke={color} strokeWidth={1.75} /> | |
| 145 | + {(showDots || pts.length < 12) && pts.map((p, j) => <circle key={j} cx={toX(p)} cy={y(p.y)} r={2.2} fill={color} />)} | |
| 146 | + <circle cx={toX(last)} cy={y(last.y)} r={3} fill={color} /> | |
| 147 | + </g> | |
| 148 | + ); | |
| 149 | + })} | |
| 150 | + </svg> | |
| 151 | + ); | |
| 152 | +} | |
| 153 | + | |
| 154 | +export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) { | |
| 155 | + return ( | |
| 156 | + <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}> | |
| 157 | + {series.map((s, i) => ( | |
| 158 | + <li key={s.name} className="flex items-center gap-1.5"> | |
| 159 | + <span className="inline-block h-[3px] w-4 rounded-sm" style={{ background: s.color ?? `var(--series-${(i % 8) + 1})` }} /> | |
| 160 | + {s.name} | |
| 161 | + </li> | |
| 162 | + ))} | |
| 163 | + </ul> | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +/** Step chart (price history): keeps a value until the next change. */ | |
| 168 | +export function stepPoints(rows: { at: string; value: number | null }[]): Point[] { | |
| 169 | + const out: Point[] = []; | |
| 170 | + for (const r of rows) { | |
| 171 | + if (r.value === null || !Number.isFinite(r.value)) continue; | |
| 172 | + const t = new Date(r.at); | |
| 173 | + if (Number.isNaN(t.getTime())) continue; | |
| 174 | + const prev = out[out.length - 1]; | |
| 175 | + if (prev) out.push({ x: new Date(t.getTime() - 1), y: prev.y }); | |
| 176 | + out.push({ x: t, y: r.value }); | |
| 177 | + } | |
| 178 | + return out; | |
| 179 | +} | |
added
apps/web/src/components/entity/blocks.tsx
+498 −0
@@ -0,0 +1,498 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ChangeRow } from '@/components/changes/change-row'; | |
| 4 | +import { Legend, LineChart, type Series, Sparkline, stepPoints } from '@/components/charts/charts'; | |
| 5 | +import { Chip, ConfidenceBadge, Estimated, TierBadge } from '@/components/ui/badges'; | |
| 6 | +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; | |
| 7 | +import { EntityInline, EntityLink, EntityRow, QualityMark } from '@/components/ui/entity'; | |
| 8 | +import { KeyValue, type KVRow } from '@/components/ui/key-value'; | |
| 9 | +import { SourceCell } from '@/components/ui/provenance'; | |
| 10 | +import { Note } from '@/components/ui/section'; | |
| 11 | +import { EmptyState } from '@/components/ui/unavailable'; | |
| 12 | +import { fmtAgo, fmtDate, fmtGb, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, fmtValue, num } from '@/lib/format'; | |
| 13 | +import { predicateLabel, PROSE_KEYS, propertyLabel, routes, typeLabel } from '@/lib/site'; | |
| 14 | +import type { BenchmarkResult, ChangeEvent, EntityDetail, EntitySummary, HardwareFitRow, Price, Provenance, RelationGroup, SourceRef } from '@/lib/types'; | |
| 15 | + | |
| 16 | +/* ------------------------------------------------------------------------------------------------------ spec table */ | |
| 17 | + | |
| 18 | +const MODEL_ORDER = ['family', 'version', 'release_date', 'status', 'openness', 'license', 'architecture', 'parameter_count', 'active_parameter_count', 'is_moe', 'context_length', 'max_output_tokens', 'knowledge_cutoff', 'training_data_cutoff', 'modalities', 'modalities_input', 'modalities_output', 'languages', 'tokenizer', 'api_model_id', 'api_alias', 'base_model', 'quantization', 'quant_format', 'file_size_gb', 'deprecation_date', 'retirement_date', 'retirement_tentative', 'hf_repo', 'pipeline_tag', 'official_url', 'model_card_url', 'paper_url', 'repository_url', 'metric.downloads', 'metric.likes']; | |
| 19 | +const CAPABILITY_KEYS = ['tool_calling', 'structured_output', 'reasoning', 'vision', 'audio', 'fine_tuning_available']; | |
| 20 | +const HIDDEN = new Set(['name', 'slug', 'id', 'entity_type']); | |
| 21 | + | |
| 22 | +/** Every non-prose attribute as a KeyValue list, model keys in canonical order, others alphabetical. */ | |
| 23 | +export function SpecTable({ d, exclude = [] }: { d: EntityDetail; exclude?: string[] }) { | |
| 24 | + const attrs = d.attributes ?? {}; | |
| 25 | + const skip = new Set([...exclude, ...HIDDEN, ...PROSE_KEYS]); | |
| 26 | + const keys = Object.keys(attrs).filter((k) => !skip.has(k) && attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== '' && !(Array.isArray(attrs[k]) && (attrs[k] as unknown[]).length === 0)); | |
| 27 | + const order = new Map(MODEL_ORDER.map((k, i) => [k, i])); | |
| 28 | + keys.sort((a, b) => (order.get(a) ?? 999) - (order.get(b) ?? 999) || a.localeCompare(b)); | |
| 29 | + const rows: KVRow[] = keys.map((k) => ({ key: k, raw: attrs[k] })); | |
| 30 | + return <KeyValue rows={rows} provenance={d.provenance} />; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Identity block: aliases + identifiers (scheme: value). */ | |
| 34 | +export function Identity({ d }: { d: EntityDetail }) { | |
| 35 | + if (!d.aliases?.length && !d.identifiers?.length) return null; | |
| 36 | + return ( | |
| 37 | + <div className="space-y-3 text-sm"> | |
| 38 | + {d.identifiers?.length > 0 && ( | |
| 39 | + <dl className="kv"> | |
| 40 | + {d.identifiers.map((i) => ( | |
| 41 | + <div key={`${i.scheme}:${i.value}`}> | |
| 42 | + <dt className="mono">{i.scheme}</dt> | |
| 43 | + <dd className="mono text-ink">{i.value}</dd> | |
| 44 | + </div> | |
| 45 | + ))} | |
| 46 | + </dl> | |
| 47 | + )} | |
| 48 | + {d.aliases?.length > 0 && ( | |
| 49 | + <p className="text-ink-3"> | |
| 50 | + Also known as: <span className="text-ink-2">{d.aliases.join(', ')}</span> | |
| 51 | + </p> | |
| 52 | + )} | |
| 53 | + </div> | |
| 54 | + ); | |
| 55 | +} | |
| 56 | + | |
| 57 | +/* ------------------------------------------------------------------------------------------------------ capabilities */ | |
| 58 | + | |
| 59 | +export function Capabilities({ d }: { d: EntityDetail }) { | |
| 60 | + const a = d.attributes ?? {}; | |
| 61 | + const mods = (Array.isArray(a.modalities) ? a.modalities : []) as string[]; | |
| 62 | + const inMods = (Array.isArray(a.modalities_input) ? a.modalities_input : []) as string[]; | |
| 63 | + const outMods = (Array.isArray(a.modalities_output) ? a.modalities_output : []) as string[]; | |
| 64 | + const flags = CAPABILITY_KEYS.map((k) => ({ key: k, value: a[k] })); | |
| 65 | + const known = flags.filter((f) => typeof f.value === 'boolean'); | |
| 66 | + return ( | |
| 67 | + <div className="space-y-6"> | |
| 68 | + <div> | |
| 69 | + <p className="eyebrow mb-2">Modalities</p> | |
| 70 | + {mods.length || inMods.length || outMods.length ? ( | |
| 71 | + <dl className="kv"> | |
| 72 | + {mods.length > 0 && ( | |
| 73 | + <div> | |
| 74 | + <dt>Modalities</dt> | |
| 75 | + <dd className="flex flex-wrap gap-1.5">{mods.map((m) => <Chip key={m}>{m}</Chip>)}</dd> | |
| 76 | + </div> | |
| 77 | + )} | |
| 78 | + {inMods.length > 0 && ( | |
| 79 | + <div> | |
| 80 | + <dt>Input</dt> | |
| 81 | + <dd className="flex flex-wrap gap-1.5">{inMods.map((m) => <Chip key={m}>{m}</Chip>)}</dd> | |
| 82 | + </div> | |
| 83 | + )} | |
| 84 | + {outMods.length > 0 && ( | |
| 85 | + <div> | |
| 86 | + <dt>Output</dt> | |
| 87 | + <dd className="flex flex-wrap gap-1.5">{outMods.map((m) => <Chip key={m}>{m}</Chip>)}</dd> | |
| 88 | + </div> | |
| 89 | + )} | |
| 90 | + </dl> | |
| 91 | + ) : ( | |
| 92 | + <p className="text-sm text-ink-3">Modalities unavailable.</p> | |
| 93 | + )} | |
| 94 | + </div> | |
| 95 | + <div> | |
| 96 | + <p className="eyebrow mb-2">Capabilities</p> | |
| 97 | + <ul className="grid grid-cols-2 gap-px border border-rule bg-rule sm:grid-cols-3"> | |
| 98 | + {flags.map((f) => { | |
| 99 | + const v = f.value; | |
| 100 | + const p = d.provenance?.[f.key]; | |
| 101 | + return ( | |
| 102 | + <li key={f.key} className="bg-canvas px-3 py-3"> | |
| 103 | + <p className="text-xs text-ink-3">{propertyLabel(f.key)}</p> | |
| 104 | + <p className={typeof v === 'boolean' ? (v ? 'mt-0.5 font-medium text-positive' : 'mt-0.5 font-medium text-ink-2') : 'mt-0.5 text-ink-3'}>{typeof v === 'boolean' ? (v ? 'Yes' : 'No') : 'Unavailable'}</p> | |
| 105 | + {p && <p className="mt-0.5 truncate text-[11px] text-ink-3">{p.source_name ?? 'source'} · T{p.tier}</p>} | |
| 106 | + </li> | |
| 107 | + ); | |
| 108 | + })} | |
| 109 | + </ul> | |
| 110 | + {known.length === 0 && <Note className="mt-2">No capability flags have been observed from a source yet — we do not infer them.</Note>} | |
| 111 | + </div> | |
| 112 | + <KeyValue rows={[{ key: 'context_length', raw: a.context_length }, { key: 'max_output_tokens', raw: a.max_output_tokens }, { key: 'knowledge_cutoff', raw: a.knowledge_cutoff }, { key: 'languages', raw: a.languages }, { key: 'tokenizer', raw: a.tokenizer }]} provenance={d.provenance} /> | |
| 113 | + </div> | |
| 114 | + ); | |
| 115 | +} | |
| 116 | + | |
| 117 | +/* ------------------------------------------------------------------------------------------------------ benchmarks */ | |
| 118 | + | |
| 119 | +function configSummary(c: Record<string, unknown>): string { | |
| 120 | + const parts = Object.entries(c ?? {}) | |
| 121 | + .filter(([, v]) => v !== null && v !== undefined && v !== '') | |
| 122 | + .slice(0, 4) | |
| 123 | + .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`); | |
| 124 | + return parts.join(' · '); | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** Results table. `perspective="model"` shows the benchmark column; `"benchmark"` shows rank + model. */ | |
| 128 | +export function ResultsTable({ results, perspective }: { results: BenchmarkResult[]; perspective: 'model' | 'benchmark' }) { | |
| 129 | + if (!results.length) return <EmptyState title="No benchmark results recorded">Results appear when a tier 1–3 source publishes them; we never copy scores without a source.</EmptyState>; | |
| 130 | + return ( | |
| 131 | + <> | |
| 132 | + <DataTable caption="Benchmark results"> | |
| 133 | + <thead> | |
| 134 | + <tr> | |
| 135 | + {perspective === 'benchmark' && <Th className="w-10">#</Th>} | |
| 136 | + <Th>{perspective === 'model' ? 'Benchmark' : 'Model'}</Th> | |
| 137 | + <Th num>Score</Th> | |
| 138 | + <Th>Metric</Th> | |
| 139 | + <Th>Config</Th> | |
| 140 | + <Th>Evaluated</Th> | |
| 141 | + <Th>Source</Th> | |
| 142 | + </tr> | |
| 143 | + </thead> | |
| 144 | + <tbody> | |
| 145 | + {results.map((r, i) => { | |
| 146 | + const target = perspective === 'model' ? r.benchmark : r.model; | |
| 147 | + return ( | |
| 148 | + <tr key={r.id}> | |
| 149 | + {perspective === 'benchmark' && <Td className="tnum text-ink-3" hideStack>{i + 1}</Td>} | |
| 150 | + <Td primary> | |
| 151 | + <EntityLink e={target} /> | |
| 152 | + {perspective === 'benchmark' && r.model.organization && <span className="ml-2 text-xs text-ink-3">{r.model.organization.name}</span>} | |
| 153 | + </Td> | |
| 154 | + <Td num label="Score" className="tnum font-medium"> | |
| 155 | + {fmtScore(r.score)} | |
| 156 | + {r.unit && r.unit !== '%' ? <span className="text-ink-3"> {r.unit}</span> : r.unit === '%' ? '%' : ''} | |
| 157 | + </Td> | |
| 158 | + <Td label="Metric" className="text-ink-2">{r.metric ?? '—'}{r.higher_is_better === false && <span className="text-ink-3"> (lower is better)</span>}</Td> | |
| 159 | + <Td label="Config" className="mono max-w-[18rem] truncate text-xs text-ink-3" title={JSON.stringify(r.config)}>{configSummary(r.config) || '—'}</Td> | |
| 160 | + <Td label="Evaluated" className="text-ink-2">{fmtDate(r.evaluated_at)}</Td> | |
| 161 | + <Td label="Source"><SourceCell url={r.source_url} tier={r.tier} /> <ConfidenceBadge confidence={r.confidence !== 'high' && r.confidence !== 'medium' ? r.confidence : null} /></Td> | |
| 162 | + </tr> | |
| 163 | + ); | |
| 164 | + })} | |
| 165 | + </tbody> | |
| 166 | + </DataTable> | |
| 167 | + <Note className="mt-3"> | |
| 168 | + Scores are reported as published, with their evaluation configuration (harness, prompting, judge). Results with different configs are not directly comparable — see <Link href="/methodology#benchmarks" className="link">methodology</Link>. | |
| 169 | + </Note> | |
| 170 | + </> | |
| 171 | + ); | |
| 172 | +} | |
| 173 | + | |
| 174 | +/* ------------------------------------------------------------------------------------------------------ prices */ | |
| 175 | + | |
| 176 | +export function PricesTable({ prices, perspective }: { prices: Price[]; perspective: 'model' | 'provider' }) { | |
| 177 | + if (!prices.length) return <EmptyState title="No current prices recorded">Prices appear when a provider publishes a public pricing page we crawl.</EmptyState>; | |
| 178 | + const sorted = [...prices].sort((a, b) => (num(a.input_per_mtok) ?? Infinity) - (num(b.input_per_mtok) ?? Infinity)); | |
| 179 | + return ( | |
| 180 | + <> | |
| 181 | + <DataTable caption="Current prices per 1M tokens"> | |
| 182 | + <thead> | |
| 183 | + <tr> | |
| 184 | + <Th>{perspective === 'model' ? 'Provider' : 'Model'}</Th> | |
| 185 | + <Th num>Input / 1M</Th> | |
| 186 | + <Th num>Output / 1M</Th> | |
| 187 | + <Th num>Cached in</Th> | |
| 188 | + <Th num>Batch in / out</Th> | |
| 189 | + <Th num>Context</Th> | |
| 190 | + <Th>Observed</Th> | |
| 191 | + <Th>Source</Th> | |
| 192 | + </tr> | |
| 193 | + </thead> | |
| 194 | + <tbody> | |
| 195 | + {sorted.map((p) => { | |
| 196 | + const target = perspective === 'model' ? p.provider : p.model; | |
| 197 | + return ( | |
| 198 | + <tr key={p.id}> | |
| 199 | + <Td primary> | |
| 200 | + <EntityLink e={target} /> | |
| 201 | + {p.provider_model_id && <span className="mono ml-2 text-[11px] text-ink-3">{p.provider_model_id}</span>} | |
| 202 | + {perspective === 'provider' && p.model.organization && <span className="ml-2 text-xs text-ink-3">{p.model.organization.name}</span>} | |
| 203 | + </Td> | |
| 204 | + <Td num label="Input / 1M" className="tnum font-medium text-accent-2">{fmtUsdPerM(p.input_per_mtok)}</Td> | |
| 205 | + <Td num label="Output / 1M" className="tnum font-medium text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td> | |
| 206 | + <Td num label="Cached input" className="tnum text-ink-2">{fmtUsdPerM(p.cached_input_per_mtok)}</Td> | |
| 207 | + <Td num label="Batch in / out" className="tnum text-ink-2">{num(p.batch_input_per_mtok) === null && num(p.batch_output_per_mtok) === null ? '—' : `${fmtUsdPerM(p.batch_input_per_mtok)} / ${fmtUsdPerM(p.batch_output_per_mtok)}`}</Td> | |
| 208 | + <Td num label="Context" className="tnum text-ink-2">{num(p.context_length) === null ? '—' : fmtTokens(p.context_length)}</Td> | |
| 209 | + <Td label="Observed" className="text-ink-2" title={p.observed_at}>{fmtAgo(p.observed_at)}</Td> | |
| 210 | + <Td label="Source"><SourceCell url={p.source_url} tier={p.tier} /></Td> | |
| 211 | + </tr> | |
| 212 | + ); | |
| 213 | + })} | |
| 214 | + </tbody> | |
| 215 | + </DataTable> | |
| 216 | + <Note className="mt-3">USD per 1M tokens as published by each provider ({sorted[0]?.currency ?? 'USD'}). Rows are append-only: every change is kept in the history below.</Note> | |
| 217 | + </> | |
| 218 | + ); | |
| 219 | +} | |
| 220 | + | |
| 221 | +/** Price history: step lines per provider (input price) + output as second chart when > 1 point. */ | |
| 222 | +export function PriceHistory({ history, perspective = 'model' }: { history: Price[]; perspective?: 'model' | 'provider' }) { | |
| 223 | + if (!history.length) return null; | |
| 224 | + const byKey = new Map<string, Price[]>(); | |
| 225 | + for (const p of history) { | |
| 226 | + const k = perspective === 'model' ? p.provider.name : p.model.name; | |
| 227 | + const arr = byKey.get(k); | |
| 228 | + if (arr) arr.push(p); | |
| 229 | + else byKey.set(k, [p]); | |
| 230 | + } | |
| 231 | + const build = (field: 'input_per_mtok' | 'output_per_mtok'): Series[] => | |
| 232 | + [...byKey.entries()].slice(0, 8).map(([name, rows]) => ({ | |
| 233 | + name, | |
| 234 | + points: stepPoints([...rows.sort((a, b) => a.valid_from.localeCompare(b.valid_from)).map((r) => ({ at: r.valid_from, value: num(r[field]) })), ...(rows.every((r) => r.valid_to) ? [] : [{ at: new Date().toISOString(), value: num([...rows].sort((a, b) => b.valid_from.localeCompare(a.valid_from))[0]?.[field]) }])]), | |
| 235 | + })); | |
| 236 | + const inSeries = build('input_per_mtok').filter((s) => s.points.length > 0); | |
| 237 | + const totalPoints = inSeries.reduce((n, s) => n + s.points.length, 0); | |
| 238 | + if (totalPoints < 2) return <Note>Price history starts with the first observation — no changes recorded yet ({history.length} row{history.length === 1 ? '' : 's'}).</Note>; | |
| 239 | + const outSeries = build('output_per_mtok').filter((s) => s.points.length > 0); | |
| 240 | + return ( | |
| 241 | + <div className="grid gap-6 md:grid-cols-2"> | |
| 242 | + <div> | |
| 243 | + <p className="eyebrow mb-2">Input price · USD / 1M tokens</p> | |
| 244 | + <LineChart series={inSeries} height={200} yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...inSeries.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} /> | |
| 245 | + </div> | |
| 246 | + <div> | |
| 247 | + <p className="eyebrow mb-2">Output price · USD / 1M tokens</p> | |
| 248 | + <LineChart series={outSeries} height={200} yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...outSeries.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} /> | |
| 249 | + </div> | |
| 250 | + <Legend series={inSeries} className="md:col-span-2" /> | |
| 251 | + </div> | |
| 252 | + ); | |
| 253 | +} | |
| 254 | + | |
| 255 | +/** Tiny per-row sparkline of input price for a model across its history. */ | |
| 256 | +export function PriceSpark({ history, provider }: { history: Price[]; provider: string }) { | |
| 257 | + const vals = history.filter((p) => p.provider.slug === provider).sort((a, b) => a.valid_from.localeCompare(b.valid_from)).map((p) => num(p.input_per_mtok)).filter((v): v is number => v !== null); | |
| 258 | + return <Sparkline values={vals} width={80} height={20} stroke="var(--accent-2)" />; | |
| 259 | +} | |
| 260 | + | |
| 261 | +/* ------------------------------------------------------------------------------------------------------ hardware fit */ | |
| 262 | + | |
| 263 | +export function HardwareFitTable({ rows }: { rows: HardwareFitRow[] }) { | |
| 264 | + if (!rows.length) return <EmptyState title="No hardware estimate available">Estimates need a parameter count; this model has none recorded from a source.</EmptyState>; | |
| 265 | + return ( | |
| 266 | + <> | |
| 267 | + <div className="mb-3 flex flex-wrap items-center gap-2"> | |
| 268 | + <Estimated /> | |
| 269 | + <Note>Memory need = bytes per parameter (4-bit ≈ 0.5 × 1.15 overhead, 8-bit 1.0, fp16 2.0) + a KV-cache allowance. Not a measurement.</Note> | |
| 270 | + </div> | |
| 271 | + <DataTable caption="Estimated hardware fit"> | |
| 272 | + <thead> | |
| 273 | + <tr> | |
| 274 | + <Th>Hardware</Th> | |
| 275 | + <Th>Quantization</Th> | |
| 276 | + <Th num>Memory</Th> | |
| 277 | + <Th num>Est. need</Th> | |
| 278 | + <Th>Fits</Th> | |
| 279 | + </tr> | |
| 280 | + </thead> | |
| 281 | + <tbody> | |
| 282 | + {rows.map((r, i) => ( | |
| 283 | + <tr key={`${r.hardware.id}-${r.quantization}-${i}`}> | |
| 284 | + <Td primary><EntityLink e={r.hardware} /></Td> | |
| 285 | + <Td label="Quantization" className="mono text-xs text-ink-2">{r.quantization}</Td> | |
| 286 | + <Td num label="Memory" className="tnum text-ink-2">{fmtGb(r.hardware.attributes?.memory_gb as never)}</Td> | |
| 287 | + <Td num label="Est. need" className="tnum">{fmtGb(r.estimated_memory_gb, 1)}</Td> | |
| 288 | + <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>{r.fits ? 'Yes' : 'No'}</Td> | |
| 289 | + </tr> | |
| 290 | + ))} | |
| 291 | + </tbody> | |
| 292 | + </DataTable> | |
| 293 | + </> | |
| 294 | + ); | |
| 295 | +} | |
| 296 | + | |
| 297 | +/* ------------------------------------------------------------------------------------------------------ lineage & relations */ | |
| 298 | + | |
| 299 | +export function LineageBlock({ d }: { d: EntityDetail }) { | |
| 300 | + const l = d.lineage; | |
| 301 | + if (!l || (!l.ancestors.length && !l.descendants.length && !l.quantizations.length)) return <EmptyState title="No lineage recorded">Lineage comes from explicit `derived_from`, `fine_tuned_from`, `distilled_from` and `quantized_from` relations stated by sources.</EmptyState>; | |
| 302 | + const Col = ({ title, items, hint }: { title: string; items: EntitySummary[]; hint: string }) => ( | |
| 303 | + <div> | |
| 304 | + <p className="eyebrow">{title} <span className="tnum text-ink-3">{items.length}</span></p> | |
| 305 | + {items.length ? ( | |
| 306 | + <ul className="mt-2 divide-y divide-rule border-y border-rule"> | |
| 307 | + {items.map((e) => ( | |
| 308 | + <li key={e.id} className="flex items-baseline justify-between gap-3 py-2 text-sm"> | |
| 309 | + <EntityLink e={e} className="font-medium" /> | |
| 310 | + <span className="tnum shrink-0 text-xs text-ink-3">{num(e.attributes?.parameter_count) !== null ? fmtParams(e.attributes.parameter_count) : e.organization?.name ?? ''}</span> | |
| 311 | + </li> | |
| 312 | + ))} | |
| 313 | + </ul> | |
| 314 | + ) : ( | |
| 315 | + <p className="mt-2 text-sm text-ink-3">{hint}</p> | |
| 316 | + )} | |
| 317 | + </div> | |
| 318 | + ); | |
| 319 | + return ( | |
| 320 | + <div className="grid gap-6 md:grid-cols-3"> | |
| 321 | + <Col title="Ancestors" items={l.ancestors} hint="None recorded." /> | |
| 322 | + <div className="md:border-x md:border-rule md:px-6"> | |
| 323 | + <p className="eyebrow">This model</p> | |
| 324 | + <p className="mt-2 text-sm font-medium">{d.name}</p> | |
| 325 | + <p className="tnum text-xs text-ink-3">{num(d.attributes?.parameter_count) !== null ? `${fmtParams(d.attributes.parameter_count)} params` : ''}</p> | |
| 326 | + <Col title="Quantizations" items={l.quantizations} hint="None recorded." /> | |
| 327 | + </div> | |
| 328 | + <Col title="Descendants" items={l.descendants} hint="None recorded." /> | |
| 329 | + </div> | |
| 330 | + ); | |
| 331 | +} | |
| 332 | + | |
| 333 | +export function RelationsBlock({ relations, exclude = [] }: { relations: RelationGroup[]; exclude?: string[] }) { | |
| 334 | + const groups = relations.filter((g) => g.items.length && !exclude.includes(g.predicate)); | |
| 335 | + if (!groups.length) return <p className="text-sm text-ink-3">No relations recorded.</p>; | |
| 336 | + return ( | |
| 337 | + <dl className="kv"> | |
| 338 | + {groups.map((g) => ( | |
| 339 | + <div key={`${g.predicate}-${g.direction}`}> | |
| 340 | + <dt>{predicateLabel(g.predicate, g.direction)}</dt> | |
| 341 | + <dd> | |
| 342 | + <EntityInline items={g.items} max={8} total={g.total} /> | |
| 343 | + {g.total > 8 && <span className="ml-1 text-xs text-ink-3">({fmtInt(g.total)} total)</span>} | |
| 344 | + </dd> | |
| 345 | + </div> | |
| 346 | + ))} | |
| 347 | + </dl> | |
| 348 | + ); | |
| 349 | +} | |
| 350 | + | |
| 351 | +/* ------------------------------------------------------------------------------------------------------ lists */ | |
| 352 | + | |
| 353 | +export function EntityList({ items, empty = 'Nothing recorded yet.', showType = false }: { items: EntitySummary[]; empty?: string; showType?: boolean }) { | |
| 354 | + if (!items.length) return <p className="text-sm text-ink-3">{empty}</p>; | |
| 355 | + return ( | |
| 356 | + <ul className="border-t border-rule"> | |
| 357 | + {items.map((e) => ( | |
| 358 | + <EntityRow key={e.id} e={e} showType={showType} /> | |
| 359 | + ))} | |
| 360 | + </ul> | |
| 361 | + ); | |
| 362 | +} | |
| 363 | + | |
| 364 | +/** Dense models table for company / provider / hardware pages. */ | |
| 365 | +export function ModelsTable({ items, total, moreHref }: { items: EntitySummary[]; total?: number; moreHref?: string }) { | |
| 366 | + if (!items.length) return <EmptyState title="No models recorded" />; | |
| 367 | + return ( | |
| 368 | + <> | |
| 369 | + <DataTable caption="Models"> | |
| 370 | + <thead> | |
| 371 | + <tr> | |
| 372 | + <Th>Model</Th> | |
| 373 | + <Th num>Params</Th> | |
| 374 | + <Th num>Context</Th> | |
| 375 | + <Th>Openness</Th> | |
| 376 | + <Th>Released</Th> | |
| 377 | + <Th>Status</Th> | |
| 378 | + <Th num>Quality</Th> | |
| 379 | + </tr> | |
| 380 | + </thead> | |
| 381 | + <tbody> | |
| 382 | + {items.map((m) => { | |
| 383 | + const a = m.attributes ?? {}; | |
| 384 | + return ( | |
| 385 | + <tr key={m.id}> | |
| 386 | + <Td primary><EntityLink e={m} /></Td> | |
| 387 | + <Td num label="Params" className="tnum">{num(a.parameter_count) === null ? '—' : fmtParams(a.parameter_count)}</Td> | |
| 388 | + <Td num label="Context" className="tnum">{num(a.context_length) === null ? '—' : fmtTokens(a.context_length)}</Td> | |
| 389 | + <Td label="Openness" className="text-ink-2">{typeof a.openness === 'string' ? fmtValue(a.openness) : '—'}</Td> | |
| 390 | + <Td label="Released" className="text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</Td> | |
| 391 | + <Td label="Status" className="text-ink-2">{m.status && m.status !== 'unknown' ? m.status : '—'}</Td> | |
| 392 | + <Td num label="Quality"><QualityMark q={m.quality?.score} /></Td> | |
| 393 | + </tr> | |
| 394 | + ); | |
| 395 | + })} | |
| 396 | + </tbody> | |
| 397 | + </DataTable> | |
| 398 | + {total !== undefined && total > items.length && moreHref && ( | |
| 399 | + <p className="mt-3 text-sm"> | |
| 400 | + <Link href={moreHref} className="link">All {fmtInt(total)} models →</Link> | |
| 401 | + </p> | |
| 402 | + )} | |
| 403 | + </> | |
| 404 | + ); | |
| 405 | +} | |
| 406 | + | |
| 407 | +/* ------------------------------------------------------------------------------------------------------ timeline & sources */ | |
| 408 | + | |
| 409 | +export function TimelineList({ events, slug }: { events: ChangeEvent[]; slug?: string }) { | |
| 410 | + if (!events.length) return <EmptyState title="No events yet">Events are generated by the change engine when a material property, price or result changes.</EmptyState>; | |
| 411 | + return ( | |
| 412 | + <> | |
| 413 | + <ul className="border-t border-rule"> | |
| 414 | + {events.map((e) => ( | |
| 415 | + <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 416 | + ))} | |
| 417 | + </ul> | |
| 418 | + {slug && ( | |
| 419 | + <p className="mt-3 text-sm"> | |
| 420 | + <Link href={`/timeline?entity=${encodeURIComponent(slug)}`} className="link">Full timeline →</Link> | |
| 421 | + </p> | |
| 422 | + )} | |
| 423 | + </> | |
| 424 | + ); | |
| 425 | +} | |
| 426 | + | |
| 427 | +export function SourcesTable({ sources }: { sources: SourceRef[] }) { | |
| 428 | + if (!sources.length) return <EmptyState title="No documents recorded" />; | |
| 429 | + const sorted = [...sources].sort((a, b) => (a.tier ?? 9) - (b.tier ?? 9) || (b.last_observed_at ?? '').localeCompare(a.last_observed_at ?? '')); | |
| 430 | + return ( | |
| 431 | + <> | |
| 432 | + <DataTable caption="Source documents"> | |
| 433 | + <thead> | |
| 434 | + <tr> | |
| 435 | + <Th>Source</Th> | |
| 436 | + <Th>Document</Th> | |
| 437 | + <Th>Type</Th> | |
| 438 | + <Th>Tier</Th> | |
| 439 | + <Th>Last observed</Th> | |
| 440 | + <Th num>Snapshots</Th> | |
| 441 | + </tr> | |
| 442 | + </thead> | |
| 443 | + <tbody> | |
| 444 | + {sorted.map((s) => ( | |
| 445 | + <tr key={s.url}> | |
| 446 | + <Td primary>{s.source_name ?? s.domain ?? '—'}</Td> | |
| 447 | + <Td label="Document" wide> | |
| 448 | + <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full items-center gap-1 break-all text-ink-2 hover:text-accent"> | |
| 449 | + <span className="truncate">{s.url.replace(/^https?:\/\/(www\.)?/, '')}</span> <ExternalLink className="size-3 shrink-0" aria-hidden /> | |
| 450 | + </a> | |
| 451 | + </Td> | |
| 452 | + <Td label="Type" className="mono text-xs text-ink-2">{s.doc_type}</Td> | |
| 453 | + <Td label="Tier"><TierBadge tier={s.tier} withLabel /></Td> | |
| 454 | + <Td label="Last observed" className="text-ink-2" title={s.last_observed_at ?? undefined}>{fmtAgo(s.last_observed_at)}</Td> | |
| 455 | + <Td num label="Snapshots" className="tnum">{fmtInt(s.snapshots)}</Td> | |
| 456 | + </tr> | |
| 457 | + ))} | |
| 458 | + </tbody> | |
| 459 | + </DataTable> | |
| 460 | + <Note className="mt-3"> | |
| 461 | + Tier 1 = official/primary, 2 = quality secondary, 3 = community, 4 = unverified. Every snapshot is archived; see <Link href="/sources" className="link">all sources</Link> and the <Link href="/methodology" className="link">methodology</Link>. | |
| 462 | + </Note> | |
| 463 | + </> | |
| 464 | + ); | |
| 465 | +} | |
| 466 | + | |
| 467 | +/** Summary of provenance across all attributes: sources count, tiers distribution, freshest observation. */ | |
| 468 | +export function ProvenanceSummary({ provenance, quality }: { provenance: Provenance; quality: EntityDetail['quality'] }) { | |
| 469 | + const entries = Object.values(provenance ?? {}); | |
| 470 | + const tiers = [1, 2, 3, 4].map((t) => ({ t, n: entries.filter((e) => e.tier === t).length })).filter((x) => x.n); | |
| 471 | + const newest = entries.map((e) => e.observed_at).sort().at(-1); | |
| 472 | + const conflicts = num(quality?.conflicts) ?? 0; | |
| 473 | + return ( | |
| 474 | + <div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4"> | |
| 475 | + <div> | |
| 476 | + <p className="eyebrow">Attributed facts</p> | |
| 477 | + <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(entries.length)}</p> | |
| 478 | + </div> | |
| 479 | + <div> | |
| 480 | + <p className="eyebrow">Source tiers</p> | |
| 481 | + <p className="mt-1 flex flex-wrap gap-1">{tiers.length ? tiers.map((x) => <TierBadge key={x.t} tier={x.t} withLabel={false} />) : <span className="text-ink-3">—</span>}{tiers.length > 0 && <span className="tnum text-xs text-ink-3">{tiers.map((x) => x.n).join(' / ')}</span>}</p> | |
| 482 | + </div> | |
| 483 | + <div> | |
| 484 | + <p className="eyebrow">Freshest observation</p> | |
| 485 | + <p className="mt-0.5 text-ink-2">{newest ? fmtAgo(newest) : '—'}</p> | |
| 486 | + </div> | |
| 487 | + <div> | |
| 488 | + <p className="eyebrow">Conflicts</p> | |
| 489 | + <p className={conflicts ? 'mt-0.5 font-medium text-danger' : 'mt-0.5 text-ink-2'}>{conflicts ? `${conflicts} flagged` : 'None'}</p> | |
| 490 | + </div> | |
| 491 | + </div> | |
| 492 | + ); | |
| 493 | +} | |
| 494 | + | |
| 495 | +export function typeTitle(e: { entity_type: string }): string { | |
| 496 | + return typeLabel(e.entity_type); | |
| 497 | +} | |
| 498 | +export { routes }; | |
added
apps/web/src/components/entity/entity-page.tsx
+474 −0
@@ -0,0 +1,474 @@ | ||
| 1 | +import { ExternalLink } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { ViewBeacon } from '@/components/layout/view-beacon'; | |
| 4 | +import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges'; | |
| 5 | +import { EntityLink, QualityMark } from '@/components/ui/entity'; | |
| 6 | +import { Container, Note } from '@/components/ui/section'; | |
| 7 | +import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs'; | |
| 8 | +import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; | |
| 9 | +import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; | |
| 10 | +import type { EntityDetail, EntitySummary } from '@/lib/types'; | |
| 11 | +import { Capabilities, EntityList, HardwareFitTable, Identity, LineageBlock, ModelsTable, PriceHistory, PricesTable, ProvenanceSummary, RelationsBlock, ResultsTable, SourcesTable, SpecTable, TimelineList } from './blocks'; | |
| 12 | + | |
| 13 | +/* ---------------------------------------------------------------------------------------------------------- header chips */ | |
| 14 | + | |
| 15 | +function headerChips(d: EntityDetail): { label: string; value: string; key: string }[] { | |
| 16 | + const a = d.attributes ?? {}; | |
| 17 | + const out: { label: string; value: string; key: string }[] = []; | |
| 18 | + const add = (key: string, label: string, value: string | null) => value && out.push({ key, label, value }); | |
| 19 | + switch (d.entity_type) { | |
| 20 | + case 'model': | |
| 21 | + case 'quantization': { | |
| 22 | + const p = num(a.parameter_count); | |
| 23 | + const ap = num(a.active_parameter_count); | |
| 24 | + add('parameter_count', 'Parameters', p === null ? null : ap !== null && ap !== p ? `${fmtParams(p)} · ${fmtParams(ap)} active` : fmtParams(p)); | |
| 25 | + add('context_length', 'Context', num(a.context_length) === null ? null : `${fmtTokens(a.context_length)} tokens`); | |
| 26 | + add('release_date', 'Released', typeof a.release_date === 'string' ? fmtDate(a.release_date) : null); | |
| 27 | + add('license', 'License', typeof a.license === 'string' ? a.license : null); | |
| 28 | + add('knowledge_cutoff', 'Knowledge cutoff', typeof a.knowledge_cutoff === 'string' ? fmtDate(a.knowledge_cutoff) : null); | |
| 29 | + break; | |
| 30 | + } | |
| 31 | + case 'company': | |
| 32 | + case 'organization': | |
| 33 | + case 'lab': | |
| 34 | + case 'university': | |
| 35 | + add('country', 'Country', typeof a.country === 'string' ? a.country : null); | |
| 36 | + add('founded', 'Founded', a.founded ? String(a.founded).slice(0, 4) : null); | |
| 37 | + add('headquarters', 'HQ', typeof a.headquarters === 'string' ? a.headquarters : null); | |
| 38 | + add('org_kind', 'Kind', typeof a.org_kind === 'string' ? a.org_kind : null); | |
| 39 | + add('employee_count', 'Employees', num(a.employee_count) === null ? null : fmtInt(a.employee_count)); | |
| 40 | + break; | |
| 41 | + case 'paper': | |
| 42 | + add('published_at', 'Published', typeof a.published_at === 'string' ? fmtDate(a.published_at) : null); | |
| 43 | + add('venue', 'Venue', typeof a.venue === 'string' ? a.venue : null); | |
| 44 | + add('arxiv_id', 'arXiv', typeof a.arxiv_id === 'string' ? a.arxiv_id : null); | |
| 45 | + add('primary_category', 'Category', typeof a.primary_category === 'string' ? a.primary_category : null); | |
| 46 | + break; | |
| 47 | + case 'hardware': | |
| 48 | + add('kind', 'Kind', typeof a.kind === 'string' ? a.kind : null); | |
| 49 | + add('memory_gb', 'Memory', num(a.memory_gb) === null ? null : `${fmtInt(a.memory_gb)} GB ${typeof a.memory_type === 'string' ? a.memory_type : ''}`.trim()); | |
| 50 | + add('memory_bandwidth_gbs', 'Bandwidth', num(a.memory_bandwidth_gbs) === null ? null : `${fmtInt(a.memory_bandwidth_gbs)} GB/s`); | |
| 51 | + add('tdp_watts', 'TDP', num(a.tdp_watts) === null ? null : `${fmtInt(a.tdp_watts)} W`); | |
| 52 | + add('release_date', 'Released', typeof a.release_date === 'string' ? fmtDate(a.release_date) : null); | |
| 53 | + break; | |
| 54 | + case 'benchmark': | |
| 55 | + add('category', 'Category', typeof a.category === 'string' ? a.category : null); | |
| 56 | + add('task', 'Task', typeof a.task === 'string' ? a.task : null); | |
| 57 | + add('metric', 'Metric', typeof a.metric === 'string' ? a.metric : null); | |
| 58 | + add('creator', 'Creator', typeof a.creator === 'string' ? a.creator : null); | |
| 59 | + break; | |
| 60 | + case 'framework': | |
| 61 | + case 'library': | |
| 62 | + case 'runtime': | |
| 63 | + case 'repository': | |
| 64 | + add('latest_version', 'Version', typeof a.latest_version === 'string' ? a.latest_version : null); | |
| 65 | + add('latest_release_at', 'Released', typeof a.latest_release_at === 'string' ? fmtDate(a.latest_release_at) : null); | |
| 66 | + add('language', 'Language', typeof a.language === 'string' ? a.language : null); | |
| 67 | + add('license', 'License', typeof a.license === 'string' ? a.license : null); | |
| 68 | + add('metric.stars', 'Stars', num(a['metric.stars']) === null ? null : fmtInt(a['metric.stars'])); | |
| 69 | + break; | |
| 70 | + case 'dataset': | |
| 71 | + add('modality', 'Modality', typeof a.modality === 'string' ? a.modality : null); | |
| 72 | + add('size', 'Size', a.size ? String(a.size) : null); | |
| 73 | + add('license', 'License', typeof a.license === 'string' ? a.license : null); | |
| 74 | + add('publisher', 'Publisher', typeof a.publisher === 'string' ? a.publisher : null); | |
| 75 | + break; | |
| 76 | + case 'provider': | |
| 77 | + add('regions', 'Regions', Array.isArray(a.regions) && a.regions.length ? `${a.regions.length}` : null); | |
| 78 | + break; | |
| 79 | + default: | |
| 80 | + break; | |
| 81 | + } | |
| 82 | + return out.slice(0, 5); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function primaryUrl(d: EntityDetail): string | null { | |
| 86 | + const a = d.attributes ?? {}; | |
| 87 | + for (const k of ['official_url', 'website', 'model_card_url', 'pdf_url', 'repository_url', 'spec_url', 'docs_url']) { | |
| 88 | + const v = a[k]; | |
| 89 | + if (typeof v === 'string' && /^https?:\/\//.test(v)) return v; | |
| 90 | + } | |
| 91 | + return null; | |
| 92 | +} | |
| 93 | + | |
| 94 | +/* ---------------------------------------------------------------------------------------------------------- tabs per type */ | |
| 95 | + | |
| 96 | +type TabKey = 'overview' | 'capabilities' | 'benchmarks' | 'providers' | 'hardware' | 'lineage' | 'research' | 'models' | 'repositories' | 'leaderboard' | 'runnable' | 'relations' | 'timeline' | 'sources'; | |
| 97 | + | |
| 98 | +function tabsFor(d: EntityDetail): TabDef[] { | |
| 99 | + const t = d.entity_type; | |
| 100 | + const c = (n: number | undefined | null) => (n ? n : undefined); | |
| 101 | + const rel = d.relations?.reduce((n, g) => n + g.items.length, 0) ?? 0; | |
| 102 | + const sources = d.sources?.length ?? 0; | |
| 103 | + const timeline = d.timeline?.length ?? 0; | |
| 104 | + const tail: TabDef[] = [ | |
| 105 | + { id: 'timeline', label: 'Timeline', count: c(timeline) }, | |
| 106 | + { id: 'sources', label: 'Sources', count: c(sources) }, | |
| 107 | + ]; | |
| 108 | + if (t === 'model' || t === 'quantization') | |
| 109 | + return [ | |
| 110 | + { id: 'overview', label: 'Overview' }, | |
| 111 | + { id: 'capabilities', label: 'Capabilities' }, | |
| 112 | + { id: 'benchmarks', label: 'Benchmarks', count: c(d.results?.length) }, | |
| 113 | + { id: 'providers', label: 'Providers & Pricing', count: c(d.prices?.length) }, | |
| 114 | + { id: 'hardware', label: 'Hardware', count: c(d.hardware_fit?.length) }, | |
| 115 | + { id: 'lineage', label: 'Lineage', count: c((d.lineage?.ancestors.length ?? 0) + (d.lineage?.descendants.length ?? 0) + (d.lineage?.quantizations.length ?? 0)) }, | |
| 116 | + { id: 'research', label: 'Research', count: c((d.papers?.length ?? 0) + (d.repositories?.length ?? 0)) }, | |
| 117 | + ...tail, | |
| 118 | + ]; | |
| 119 | + if (['company', 'organization', 'lab', 'university'].includes(t)) | |
| 120 | + return [ | |
| 121 | + { id: 'overview', label: 'Overview' }, | |
| 122 | + { id: 'models', label: 'Models', count: c(d.models?.total ?? d.models?.items.length) }, | |
| 123 | + { id: 'research', label: 'Research', count: c(d.papers?.length) }, | |
| 124 | + { id: 'providers', label: 'Providers', count: c(d.relations?.filter((g) => g.predicate === 'available_through' || g.predicate === 'operates').reduce((n, g) => n + g.items.length, 0)) }, | |
| 125 | + { id: 'repositories', label: 'Repositories', count: c(d.repositories?.length) }, | |
| 126 | + ...tail, | |
| 127 | + ]; | |
| 128 | + if (t === 'provider') | |
| 129 | + return [ | |
| 130 | + { id: 'overview', label: 'Overview' }, | |
| 131 | + { id: 'providers', label: 'Models & Pricing', count: c(d.prices?.length ?? d.models?.total) }, | |
| 132 | + { id: 'relations', label: 'Relations', count: c(rel) }, | |
| 133 | + ...tail, | |
| 134 | + ]; | |
| 135 | + if (t === 'benchmark') | |
| 136 | + return [ | |
| 137 | + { id: 'overview', label: 'Overview' }, | |
| 138 | + { id: 'leaderboard', label: 'Leaderboard', count: c(d.results?.length) }, | |
| 139 | + { id: 'relations', label: 'Relations', count: c(rel) }, | |
| 140 | + ...tail, | |
| 141 | + ]; | |
| 142 | + if (t === 'hardware') | |
| 143 | + return [ | |
| 144 | + { id: 'overview', label: 'Overview' }, | |
| 145 | + { id: 'runnable', label: 'Runnable models', count: c(d.models?.total ?? d.models?.items.length) }, | |
| 146 | + { id: 'relations', label: 'Relations', count: c(rel) }, | |
| 147 | + ...tail, | |
| 148 | + ]; | |
| 149 | + if (t === 'paper') | |
| 150 | + return [ | |
| 151 | + { id: 'overview', label: 'Overview' }, | |
| 152 | + { id: 'models', label: 'Related models', count: c(d.relations?.filter((g) => g.items.some((i) => i.entity_type === 'model')).reduce((n, g) => n + g.items.filter((i) => i.entity_type === 'model').length, 0)) }, | |
| 153 | + { id: 'relations', label: 'Relations', count: c(rel) }, | |
| 154 | + ...tail, | |
| 155 | + ]; | |
| 156 | + return [{ id: 'overview', label: 'Overview' }, { id: 'relations', label: 'Relations', count: c(rel) }, ...tail]; | |
| 157 | +} | |
| 158 | + | |
| 159 | +/* ---------------------------------------------------------------------------------------------------------- JSON-LD */ | |
| 160 | + | |
| 161 | +function jsonLd(d: EntityDetail, canonical: string) { | |
| 162 | + const a = d.attributes ?? {}; | |
| 163 | + const url = `${SITE_URL}${canonical}`; | |
| 164 | + const org = d.organization ? { '@type': 'Organization', name: d.organization.name, url: `${SITE_URL}${routes.entity({ entity_type: 'company', slug: d.organization.slug })}` } : undefined; | |
| 165 | + const base: Record<string, unknown> = { '@context': 'https://schema.org', name: d.name, url, description: d.description ?? undefined, alternateName: d.aliases?.length ? d.aliases : undefined, identifier: d.identifiers?.map((i) => ({ '@type': 'PropertyValue', propertyID: i.scheme, value: i.value })) }; | |
| 166 | + switch (d.entity_type) { | |
| 167 | + case 'model': | |
| 168 | + case 'quantization': | |
| 169 | + return { ...base, '@type': ['SoftwareApplication', 'Product'], applicationCategory: 'AI model', creator: org, manufacturer: org, datePublished: typeof a.release_date === 'string' ? a.release_date : undefined, license: typeof a.license === 'string' ? a.license : undefined, offers: d.prices?.length ? d.prices.slice(0, 8).map((p) => ({ '@type': 'Offer', seller: { '@type': 'Organization', name: p.provider.name }, price: num(p.input_per_mtok) ?? undefined, priceCurrency: p.currency || 'USD', description: 'Input price per 1M tokens' })) : undefined }; | |
| 170 | + case 'company': | |
| 171 | + case 'organization': | |
| 172 | + case 'lab': | |
| 173 | + case 'university': | |
| 174 | + return { ...base, '@type': 'Organization', foundingDate: a.founded ? String(a.founded) : undefined, sameAs: typeof a.website === 'string' ? [a.website] : undefined, location: typeof a.headquarters === 'string' ? a.headquarters : undefined }; | |
| 175 | + case 'paper': | |
| 176 | + return { ...base, '@type': 'ScholarlyArticle', headline: d.name, datePublished: typeof a.published_at === 'string' ? a.published_at : undefined, author: Array.isArray(a.authors) ? (a.authors as unknown[]).slice(0, 30).map((n) => ({ '@type': 'Person', name: String(n) })) : undefined, sameAs: [a.pdf_url, a.arxiv_id ? `https://arxiv.org/abs/${a.arxiv_id}` : null].filter(Boolean), abstract: typeof a.abstract === 'string' ? a.abstract : undefined }; | |
| 177 | + case 'hardware': | |
| 178 | + return { ...base, '@type': 'Product', manufacturer: typeof a.manufacturer === 'string' ? { '@type': 'Organization', name: a.manufacturer } : org, category: typeof a.kind === 'string' ? a.kind : undefined }; | |
| 179 | + case 'framework': | |
| 180 | + case 'library': | |
| 181 | + case 'runtime': | |
| 182 | + case 'repository': | |
| 183 | + case 'tool': | |
| 184 | + return { ...base, '@type': 'SoftwareSourceCode', codeRepository: typeof a.repository_url === 'string' ? a.repository_url : undefined, programmingLanguage: typeof a.language === 'string' ? a.language : undefined, license: typeof a.license === 'string' ? a.license : undefined, author: org }; | |
| 185 | + case 'dataset': | |
| 186 | + return { ...base, '@type': 'Dataset', license: typeof a.license === 'string' ? a.license : undefined, creator: org }; | |
| 187 | + default: | |
| 188 | + return { ...base, '@type': 'Thing' }; | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +/* ---------------------------------------------------------------------------------------------------------- page */ | |
| 193 | + | |
| 194 | +/** | |
| 195 | + * Shared entity page for every type. Header (badges, name, org, description, key chips) + URL-driven tabs whose set | |
| 196 | + * depends on the type. Panels are all server-rendered (SEO); the client Tabs only toggles visibility. | |
| 197 | + */ | |
| 198 | +export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonical: string; related?: EntitySummary[] | null }) { | |
| 199 | + const a = d.attributes ?? {}; | |
| 200 | + const chips = headerChips(d); | |
| 201 | + const openness = typeof a.openness === 'string' ? a.openness : null; | |
| 202 | + const link = primaryUrl(d); | |
| 203 | + const tabs = tabsFor(d); | |
| 204 | + const isModel = d.entity_type === 'model' || d.entity_type === 'quantization'; | |
| 205 | + const isCompany = ['company', 'organization', 'lab', 'university'].includes(d.entity_type); | |
| 206 | + const ld = jsonLd(d, canonical); | |
| 207 | + const prose = typeof a.abstract === 'string' ? a.abstract : null; | |
| 208 | + const modelRelations = d.relations?.filter((g) => g.items.some((i) => i.entity_type === 'model')).flatMap((g) => g.items.filter((i) => i.entity_type === 'model')) ?? []; | |
| 209 | + | |
| 210 | + return ( | |
| 211 | + <Container wide> | |
| 212 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> | |
| 213 | + <ViewBeacon path={canonical} /> | |
| 214 | + | |
| 215 | + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3"> | |
| 216 | + <ol className="flex flex-wrap items-center gap-1.5"> | |
| 217 | + <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li> | |
| 218 | + <li aria-hidden>/</li> | |
| 219 | + <li><Link href={routes.listing(d.entity_type)} className="hover:text-ink">{typeLabel(d.entity_type, true)}</Link></li> | |
| 220 | + {d.organization && isModel && ( | |
| 221 | + <> | |
| 222 | + <li aria-hidden>/</li> | |
| 223 | + <li><Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="hover:text-ink">{d.organization.name}</Link></li> | |
| 224 | + </> | |
| 225 | + )} | |
| 226 | + <li aria-hidden>/</li> | |
| 227 | + <li className="text-ink-2">{d.name}</li> | |
| 228 | + </ol> | |
| 229 | + </nav> | |
| 230 | + | |
| 231 | + <header className="pb-6 pt-4 md:pb-8 md:pt-5"> | |
| 232 | + <div className="flex flex-wrap items-center gap-2"> | |
| 233 | + <EntityBadge type={d.entity_type} /> | |
| 234 | + <StatusBadge status={d.status} /> | |
| 235 | + {openness && <OpennessBadge openness={openness} />} | |
| 236 | + {typeof a.family === 'string' && <span className="text-xs text-ink-3">family · {a.family}</span>} | |
| 237 | + </div> | |
| 238 | + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> | |
| 239 | + <div className="min-w-0"> | |
| 240 | + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1> | |
| 241 | + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2"> | |
| 242 | + {d.organization && ( | |
| 243 | + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent"> | |
| 244 | + {d.organization.name} | |
| 245 | + </Link> | |
| 246 | + )} | |
| 247 | + {link && ( | |
| 248 | + <a href={link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent"> | |
| 249 | + {link.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)} <ExternalLink className="size-3.5" aria-hidden /> | |
| 250 | + </a> | |
| 251 | + )} | |
| 252 | + </p> | |
| 253 | + {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} | |
| 254 | + </div> | |
| 255 | + <div className="shrink-0 text-xs text-ink-3 lg:text-right"> | |
| 256 | + <QualityMark q={d.quality?.score} label /> | |
| 257 | + <p className="mt-1" title={d.updated_at}>Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}</p> | |
| 258 | + <p className="mono mt-0.5 text-[11px]">{d.id}</p> | |
| 259 | + </div> | |
| 260 | + </div> | |
| 261 | + {chips.length > 0 && ( | |
| 262 | + <dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-3 lg:grid-cols-5"> | |
| 263 | + {chips.map((c) => { | |
| 264 | + const p = d.provenance?.[c.key]; | |
| 265 | + return ( | |
| 266 | + <div key={c.key} className="min-w-0"> | |
| 267 | + <dt className="eyebrow">{c.label}</dt> | |
| 268 | + <dd className="tnum mt-0.5 truncate text-[15px] font-medium text-ink" title={c.value}>{c.value}</dd> | |
| 269 | + {p && <dd className="truncate text-[11px] text-ink-3" title={`${p.source_name ?? p.url ?? ''} · tier ${p.tier} · observed ${fmtAgo(p.observed_at)}`}>T{p.tier} · {fmtAgo(p.observed_at)}</dd>} | |
| 270 | + </div> | |
| 271 | + ); | |
| 272 | + })} | |
| 273 | + </dl> | |
| 274 | + )} | |
| 275 | + </header> | |
| 276 | + | |
| 277 | + <Tabs tabs={tabs} className="pb-10"> | |
| 278 | + {/* ------------------------------------------------------------------------------------------ Overview */} | |
| 279 | + <TabPanel id="overview"> | |
| 280 | + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_22rem]"> | |
| 281 | + <div className="min-w-0 space-y-8"> | |
| 282 | + {prose && ( | |
| 283 | + <section> | |
| 284 | + <p className="eyebrow mb-2">Abstract</p> | |
| 285 | + <p className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2">{prose}</p> | |
| 286 | + </section> | |
| 287 | + )} | |
| 288 | + {d.entity_type === 'paper' && Array.isArray(a.authors) && (a.authors as unknown[]).length > 0 && ( | |
| 289 | + <section> | |
| 290 | + <p className="eyebrow mb-2">Authors <span className="tnum text-ink-3">{(a.authors as unknown[]).length}</span></p> | |
| 291 | + <p className="text-sm text-ink-2">{(a.authors as unknown[]).map(String).join(', ')}</p> | |
| 292 | + </section> | |
| 293 | + )} | |
| 294 | + <section> | |
| 295 | + <p className="eyebrow mb-2">Specification</p> | |
| 296 | + <SpecTable d={d} exclude={isModel ? [...['tool_calling', 'structured_output', 'reasoning', 'vision', 'audio', 'fine_tuning_available'], 'authors'] : ['authors']} /> | |
| 297 | + <Note className="mt-3"> | |
| 298 | + Each value shows its source, tier and observation time. Conflicting claims are kept side by side and flagged — never averaged. <Link href="/methodology" className="link">How AI Atlas records facts →</Link> | |
| 299 | + </Note> | |
| 300 | + </section> | |
| 301 | + <section> | |
| 302 | + <p className="eyebrow mb-2">Provenance</p> | |
| 303 | + <ProvenanceSummary provenance={d.provenance} quality={d.quality} /> | |
| 304 | + </section> | |
| 305 | + </div> | |
| 306 | + <aside className="min-w-0 space-y-8"> | |
| 307 | + <section> | |
| 308 | + <p className="eyebrow mb-2">Relations</p> | |
| 309 | + <RelationsBlock relations={d.relations ?? []} /> | |
| 310 | + </section> | |
| 311 | + <section> | |
| 312 | + <p className="eyebrow mb-2">Identity</p> | |
| 313 | + <Identity d={d} /> | |
| 314 | + <p className="mono mt-2 break-all text-[11px] text-ink-3">slug {d.slug}</p> | |
| 315 | + </section> | |
| 316 | + {related && related.length > 0 && ( | |
| 317 | + <section> | |
| 318 | + <p className="eyebrow mb-2">Related</p> | |
| 319 | + <ul className="divide-y divide-rule border-y border-rule"> | |
| 320 | + {related.slice(0, 8).map((r) => ( | |
| 321 | + <li key={r.id} className="flex items-center gap-2 py-2 text-sm"> | |
| 322 | + <EntityBadge type={r.entity_type} small /> | |
| 323 | + <EntityLink e={r} className="truncate" /> | |
| 324 | + {r.organization && <span className="ml-auto shrink-0 text-xs text-ink-3">{r.organization.name}</span>} | |
| 325 | + </li> | |
| 326 | + ))} | |
| 327 | + </ul> | |
| 328 | + </section> | |
| 329 | + )} | |
| 330 | + <section> | |
| 331 | + <p className="eyebrow mb-2">Compare</p> | |
| 332 | + <p className="text-sm text-ink-2"> | |
| 333 | + <Link href={routes.compare([d.slug])} className="link">Add {d.name} to a comparison →</Link> | |
| 334 | + </p> | |
| 335 | + </section> | |
| 336 | + </aside> | |
| 337 | + </div> | |
| 338 | + </TabPanel> | |
| 339 | + | |
| 340 | + {/* ------------------------------------------------------------------------------------------ Model tabs */} | |
| 341 | + {isModel && ( | |
| 342 | + <TabPanel id="capabilities"> | |
| 343 | + <Capabilities d={d} /> | |
| 344 | + </TabPanel> | |
| 345 | + )} | |
| 346 | + {isModel && ( | |
| 347 | + <TabPanel id="benchmarks"> | |
| 348 | + <ResultsTable results={d.results ?? []} perspective="model" /> | |
| 349 | + </TabPanel> | |
| 350 | + )} | |
| 351 | + {(isModel || d.entity_type === 'provider') && ( | |
| 352 | + <TabPanel id="providers"> | |
| 353 | + <div className="space-y-8"> | |
| 354 | + {d.entity_type === 'provider' && d.models && d.models.items.length > 0 && !(d.prices?.length) && ( | |
| 355 | + <section> | |
| 356 | + <p className="eyebrow mb-2">Models served</p> | |
| 357 | + <ModelsTable items={d.models.items} total={d.models.total} moreHref={`/models?provider=${encodeURIComponent(d.slug)}`} /> | |
| 358 | + </section> | |
| 359 | + )} | |
| 360 | + <section> | |
| 361 | + <p className="eyebrow mb-2">Current prices</p> | |
| 362 | + <PricesTable prices={d.prices ?? []} perspective={isModel ? 'model' : 'provider'} /> | |
| 363 | + </section> | |
| 364 | + {isModel && d.providers && d.providers.length > 0 && ( | |
| 365 | + <section> | |
| 366 | + <p className="eyebrow mb-2">Available through <span className="tnum text-ink-3">{d.providers.length}</span></p> | |
| 367 | + <p className="flex flex-wrap gap-x-3 gap-y-1 text-sm"> | |
| 368 | + {d.providers.map((p) => ( | |
| 369 | + <EntityLink key={p.id} e={p} /> | |
| 370 | + ))} | |
| 371 | + </p> | |
| 372 | + </section> | |
| 373 | + )} | |
| 374 | + <section> | |
| 375 | + <p className="eyebrow mb-2">Price history</p> | |
| 376 | + <PriceHistory history={d.price_history ?? d.prices ?? []} perspective={isModel ? 'model' : 'provider'} /> | |
| 377 | + </section> | |
| 378 | + </div> | |
| 379 | + </TabPanel> | |
| 380 | + )} | |
| 381 | + {isModel && ( | |
| 382 | + <TabPanel id="hardware"> | |
| 383 | + <HardwareFitTable rows={d.hardware_fit ?? []} /> | |
| 384 | + </TabPanel> | |
| 385 | + )} | |
| 386 | + {isModel && ( | |
| 387 | + <TabPanel id="lineage"> | |
| 388 | + <LineageBlock d={d} /> | |
| 389 | + </TabPanel> | |
| 390 | + )} | |
| 391 | + {(isModel || isCompany) && ( | |
| 392 | + <TabPanel id="research"> | |
| 393 | + <div className="grid gap-10 lg:grid-cols-2"> | |
| 394 | + <section> | |
| 395 | + <p className="eyebrow mb-2">Papers <span className="tnum text-ink-3">{d.papers?.length ?? 0}</span></p> | |
| 396 | + <EntityList items={d.papers ?? []} empty="No papers linked yet." /> | |
| 397 | + </section> | |
| 398 | + <section> | |
| 399 | + <p className="eyebrow mb-2">Repositories <span className="tnum text-ink-3">{d.repositories?.length ?? 0}</span></p> | |
| 400 | + <EntityList items={d.repositories ?? []} empty="No repositories linked yet." /> | |
| 401 | + </section> | |
| 402 | + </div> | |
| 403 | + </TabPanel> | |
| 404 | + )} | |
| 405 | + | |
| 406 | + {/* ------------------------------------------------------------------------------------------ Company tabs */} | |
| 407 | + {isCompany && ( | |
| 408 | + <TabPanel id="models"> | |
| 409 | + <ModelsTable items={d.models?.items ?? []} total={d.models?.total} moreHref={`/models?org=${encodeURIComponent(d.slug)}`} /> | |
| 410 | + </TabPanel> | |
| 411 | + )} | |
| 412 | + {isCompany && ( | |
| 413 | + <TabPanel id="providers"> | |
| 414 | + <RelationsBlock relations={(d.relations ?? []).filter((g) => ['available_through', 'operates', 'owns'].includes(g.predicate))} /> | |
| 415 | + </TabPanel> | |
| 416 | + )} | |
| 417 | + {isCompany && ( | |
| 418 | + <TabPanel id="repositories"> | |
| 419 | + <EntityList items={d.repositories ?? []} empty="No repositories linked yet." /> | |
| 420 | + </TabPanel> | |
| 421 | + )} | |
| 422 | + | |
| 423 | + {/* ------------------------------------------------------------------------------------------ Other types */} | |
| 424 | + {d.entity_type === 'benchmark' && ( | |
| 425 | + <TabPanel id="leaderboard"> | |
| 426 | + <ResultsTable results={d.results ?? []} perspective="benchmark" /> | |
| 427 | + </TabPanel> | |
| 428 | + )} | |
| 429 | + {d.entity_type === 'hardware' && ( | |
| 430 | + <TabPanel id="runnable"> | |
| 431 | + <div className="mb-3 flex flex-wrap items-center gap-2"> | |
| 432 | + <span className="inline-flex items-center border border-dashed border-warning/60 px-1.5 text-[11px] font-medium uppercase tracking-wide text-warning">Estimated</span> | |
| 433 | + <Note>Models whose estimated memory footprint fits this device — see <Link href="/methodology#estimates" className="link">estimate method</Link>.</Note> | |
| 434 | + </div> | |
| 435 | + <ModelsTable items={d.models?.items ?? []} total={d.models?.total} /> | |
| 436 | + </TabPanel> | |
| 437 | + )} | |
| 438 | + {d.entity_type === 'paper' && ( | |
| 439 | + <TabPanel id="models"> | |
| 440 | + <EntityList items={modelRelations} empty="No models linked to this paper yet." /> | |
| 441 | + </TabPanel> | |
| 442 | + )} | |
| 443 | + {!isModel && !isCompany && ( | |
| 444 | + <TabPanel id="relations"> | |
| 445 | + <RelationsBlock relations={d.relations ?? []} /> | |
| 446 | + </TabPanel> | |
| 447 | + )} | |
| 448 | + | |
| 449 | + <TabPanel id="timeline"> | |
| 450 | + <TimelineList events={d.timeline ?? []} slug={d.slug} /> | |
| 451 | + </TabPanel> | |
| 452 | + <TabPanel id="sources"> | |
| 453 | + <SourcesTable sources={d.sources ?? []} /> | |
| 454 | + </TabPanel> | |
| 455 | + </Tabs> | |
| 456 | + </Container> | |
| 457 | + ); | |
| 458 | +} | |
| 459 | + | |
| 460 | +export function describeEntity(d: EntityDetail): string { | |
| 461 | + const a = d.attributes ?? {}; | |
| 462 | + const bits: string[] = []; | |
| 463 | + const t = typeLabel(d.entity_type).toLowerCase(); | |
| 464 | + bits.push(`${d.name} is a${/^[aeiou]/.test(t) ? 'n' : ''} ${t}${d.organization ? ` by ${d.organization.name}` : ''}`); | |
| 465 | + if (d.entity_type === 'model') { | |
| 466 | + if (num(a.parameter_count) !== null) bits.push(`with ${fmtParams(a.parameter_count)} parameters`); | |
| 467 | + if (num(a.context_length) !== null) bits.push(`and a ${fmtTokens(a.context_length)}-token context window`); | |
| 468 | + if (typeof a.release_date === 'string') bits.push(`released ${fmtDate(a.release_date)}`); | |
| 469 | + } | |
| 470 | + let s = bits.join(' ') + '.'; | |
| 471 | + if (d.description) s = `${d.description.slice(0, 160)}${d.description.length > 160 ? '…' : ''} ${s}`; | |
| 472 | + s += ` Specifications, prices, benchmarks, lineage, timeline and sources on ${SITE_NAME}.`; | |
| 473 | + return s.slice(0, 300); | |
| 474 | +} | |
added
apps/web/src/components/entity/load.ts
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import type { Metadata } from 'next'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { api, ApiError, safe } from '@/lib/api'; | |
| 5 | +import { PATH_TYPES, routes, SITE_NAME, SITE_URL, TYPE_PATH, typeLabel } from '@/lib/site'; | |
| 6 | +import type { EntityDetail } from '@/lib/types'; | |
| 7 | +import { describeEntity } from './entity-page'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Load an entity for a typed path (`models`, `companies`, `providers`…). 404 when the API says so or when the entity's | |
| 11 | + * type is not accepted under this path (so /tools/<framework-slug> is a 404, not a duplicate canonical URL). | |
| 12 | + */ | |
| 13 | +export async function loadEntity(typePath: string, slug: string): Promise<EntityDetail> { | |
| 14 | + const def = PATH_TYPES[typePath]; | |
| 15 | + if (!def) notFound(); | |
| 16 | + try { | |
| 17 | + const d = def.api === 'entities' ? await api.entity(slug) : await api.entityOfType(def.api, slug); | |
| 18 | + if (!def.types.includes(d.entity_type)) notFound(); | |
| 19 | + return d; | |
| 20 | + } catch (e) { | |
| 21 | + if (e instanceof ApiError && e.notFound) notFound(); | |
| 22 | + throw e; | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** Load for the /explore/<type>/<slug> fallback: any type that has no dedicated path. */ | |
| 27 | +export async function loadAnyEntity(type: string, slug: string): Promise<EntityDetail> { | |
| 28 | + try { | |
| 29 | + const d = await api.entity(slug); | |
| 30 | + if (d.entity_type !== type) notFound(); | |
| 31 | + return d; | |
| 32 | + } catch (e) { | |
| 33 | + if (e instanceof ApiError && e.notFound) notFound(); | |
| 34 | + throw e; | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +export async function entityMetadata(typePath: string, slug: string): Promise<Metadata> { | |
| 39 | + const def = PATH_TYPES[typePath]; | |
| 40 | + const d = def ? await safe(def.api === 'entities' ? api.entity(slug) : api.entityOfType(def.api, slug)) : null; | |
| 41 | + if (!d || (def && !def.types.includes(d.entity_type))) return { title: def?.singular ?? 'Entity', robots: { index: false } }; | |
| 42 | + return buildMetadata(d); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function buildMetadata(d: EntityDetail): Metadata { | |
| 46 | + const canonical = routes.entity(d); | |
| 47 | + const label = typeLabel(d.entity_type); | |
| 48 | + const title = d.organization && d.entity_type === 'model' ? `${d.name} — ${d.organization.name} ${label.toLowerCase()}` : `${d.name} — ${label}`; | |
| 49 | + const description = describeEntity(d); | |
| 50 | + return { | |
| 51 | + title, | |
| 52 | + description, | |
| 53 | + alternates: { canonical }, | |
| 54 | + openGraph: { title: `${title} | ${SITE_NAME}`, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME }, | |
| 55 | + twitter: { card: 'summary_large_image', title, description }, | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function canonicalFor(d: EntityDetail): string { | |
| 60 | + return routes.entity(d); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export { TYPE_PATH }; | |
added
apps/web/src/components/layout/mobile-tab-bar.tsx
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Activity, Compass, Home, MoreHorizontal, Search } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { usePathname } from 'next/navigation'; | |
| 5 | +import { useEffect, useState } from 'react'; | |
| 6 | +import { cn } from '@/lib/cn'; | |
| 7 | +import { exploreNav, moreNav, primaryNav, routes } from '@/lib/site'; | |
| 8 | +import { useOpenSearch } from './search-context'; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Bottom tab bar (< md): Home · Explore · Changes · Search · More. Safe-area aware; <body> reserves | |
| 12 | + * `pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom))]` so it never covers content. | |
| 13 | + */ | |
| 14 | +export function MobileTabBar() { | |
| 15 | + const pathname = usePathname(); | |
| 16 | + const openSearch = useOpenSearch(); | |
| 17 | + const [more, setMore] = useState(false); | |
| 18 | + useEffect(() => setMore(false), [pathname]); | |
| 19 | + const exploreActive = pathname.startsWith('/explore') || exploreNav.some((n) => pathname.startsWith(n.href)); | |
| 20 | + const cls = (active: boolean) => cn('flex h-full w-full flex-col items-center justify-center gap-0.5 text-[10.5px]', active ? 'text-accent' : 'text-ink-2'); | |
| 21 | + return ( | |
| 22 | + <> | |
| 23 | + {more && ( | |
| 24 | + <div className="fixed inset-0 z-[45] bg-black/40 md:hidden" onClick={() => setMore(false)}> | |
| 25 | + <div className="panel absolute inset-x-2 bottom-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px)+8px)] max-h-[72vh] overflow-y-auto p-3" onClick={(e) => e.stopPropagation()}> | |
| 26 | + <p className="eyebrow px-2 pb-1">Explore</p> | |
| 27 | + <ul className="grid grid-cols-2 gap-0.5"> | |
| 28 | + {exploreNav.map((n) => ( | |
| 29 | + <li key={n.href}> | |
| 30 | + <Link href={n.href} className="block px-3 py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 31 | + {n.label} | |
| 32 | + </Link> | |
| 33 | + </li> | |
| 34 | + ))} | |
| 35 | + </ul> | |
| 36 | + <p className="eyebrow px-2 pt-3 pb-1">Track & about</p> | |
| 37 | + <ul className="grid grid-cols-2 gap-0.5"> | |
| 38 | + {[...primaryNav.filter((n) => n.href !== '/changes'), ...moreNav.filter((n) => n.href !== '/developers')].map((n) => ( | |
| 39 | + <li key={n.href}> | |
| 40 | + <Link href={n.href} className="block px-3 py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 41 | + {n.label} | |
| 42 | + </Link> | |
| 43 | + </li> | |
| 44 | + ))} | |
| 45 | + </ul> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + )} | |
| 49 | + <nav aria-label="Primary (mobile)" className="safe-bottom fixed inset-x-0 bottom-0 z-[46] border-t border-rule bg-canvas/95 backdrop-blur-md md:hidden"> | |
| 50 | + <ul className="grid h-[var(--tabbar-h)] grid-cols-5"> | |
| 51 | + <li className="min-w-0"> | |
| 52 | + <Link href={routes.home()} aria-current={pathname === '/' ? 'page' : undefined} className={cls(pathname === '/')}> | |
| 53 | + <Home size={21} aria-hidden strokeWidth={pathname === '/' ? 2.25 : 1.75} /> | |
| 54 | + <span className="truncate">Home</span> | |
| 55 | + </Link> | |
| 56 | + </li> | |
| 57 | + <li className="min-w-0"> | |
| 58 | + <Link href={routes.explore()} aria-current={exploreActive ? 'page' : undefined} className={cls(exploreActive)}> | |
| 59 | + <Compass size={21} aria-hidden strokeWidth={exploreActive ? 2.25 : 1.75} /> | |
| 60 | + <span className="truncate">Explore</span> | |
| 61 | + </Link> | |
| 62 | + </li> | |
| 63 | + <li className="min-w-0"> | |
| 64 | + <Link href={routes.changes()} aria-current={pathname.startsWith('/changes') ? 'page' : undefined} className={cls(pathname.startsWith('/changes'))}> | |
| 65 | + <Activity size={21} aria-hidden strokeWidth={pathname.startsWith('/changes') ? 2.25 : 1.75} /> | |
| 66 | + <span className="truncate">Changes</span> | |
| 67 | + </Link> | |
| 68 | + </li> | |
| 69 | + <li className="min-w-0"> | |
| 70 | + <button type="button" onClick={openSearch} className={cls(false)}> | |
| 71 | + <Search size={21} aria-hidden strokeWidth={1.75} /> | |
| 72 | + <span className="truncate">Search</span> | |
| 73 | + </button> | |
| 74 | + </li> | |
| 75 | + <li className="min-w-0"> | |
| 76 | + <button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={cls(more)}> | |
| 77 | + <MoreHorizontal size={21} aria-hidden strokeWidth={1.75} /> | |
| 78 | + <span className="truncate">More</span> | |
| 79 | + </button> | |
| 80 | + </li> | |
| 81 | + </ul> | |
| 82 | + </nav> | |
| 83 | + </> | |
| 84 | + ); | |
| 85 | +} | |
added
apps/web/src/components/layout/search-context.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; | |
| 3 | + | |
| 4 | +const Ctx = createContext<{ open: boolean; setOpen: (v: boolean) => void }>({ open: false, setOpen: () => undefined }); | |
| 5 | + | |
| 6 | +/** Global ⌘K / "/" search dialog state. */ | |
| 7 | +export function SearchProvider({ children }: { children: ReactNode }) { | |
| 8 | + const [open, setOpen] = useState(false); | |
| 9 | + useEffect(() => { | |
| 10 | + const onKey = (e: KeyboardEvent) => { | |
| 11 | + const target = e.target as HTMLElement | null; | |
| 12 | + const typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); | |
| 13 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { | |
| 14 | + e.preventDefault(); | |
| 15 | + setOpen((o) => !o); | |
| 16 | + } else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey) { | |
| 17 | + e.preventDefault(); | |
| 18 | + setOpen(true); | |
| 19 | + } | |
| 20 | + }; | |
| 21 | + window.addEventListener('keydown', onKey); | |
| 22 | + return () => window.removeEventListener('keydown', onKey); | |
| 23 | + }, []); | |
| 24 | + const value = useMemo(() => ({ open, setOpen }), [open]); | |
| 25 | + return <Ctx.Provider value={value}>{children}</Ctx.Provider>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export function useSearch() { | |
| 29 | + return useContext(Ctx); | |
| 30 | +} | |
| 31 | +export function useOpenSearch() { | |
| 32 | + const { setOpen } = useContext(Ctx); | |
| 33 | + return useCallback(() => setOpen(true), [setOpen]); | |
| 34 | +} | |
added
apps/web/src/components/layout/search-dialog.tsx
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowRight, CornerDownLeft, Search, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useEffect, useRef, useState } from 'react'; | |
| 6 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 7 | +import { clientApi } from '@/lib/client-api'; | |
| 8 | +import { cn } from '@/lib/cn'; | |
| 9 | +import { EXAMPLE_QUERIES, exploreNav, routes } from '@/lib/site'; | |
| 10 | +import type { Suggestion } from '@/lib/types'; | |
| 11 | +import { useSearch } from './search-context'; | |
| 12 | + | |
| 13 | +/** Command-palette global search (⌘K or /): prefix suggestions from /search/suggest; Enter → /search?q=. Mobile: full-screen sheet. */ | |
| 14 | +export function SearchDialog() { | |
| 15 | + const { open, setOpen } = useSearch(); | |
| 16 | + const router = useRouter(); | |
| 17 | + const [q, setQ] = useState(''); | |
| 18 | + const [items, setItems] = useState<Suggestion[]>([]); | |
| 19 | + const [active, setActive] = useState(0); | |
| 20 | + const [loading, setLoading] = useState(false); | |
| 21 | + const [failed, setFailed] = useState(false); | |
| 22 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 23 | + | |
| 24 | + useEffect(() => { | |
| 25 | + if (open) { | |
| 26 | + setTimeout(() => inputRef.current?.focus(), 20); | |
| 27 | + document.body.style.overflow = 'hidden'; | |
| 28 | + } else { | |
| 29 | + document.body.style.overflow = ''; | |
| 30 | + setQ(''); | |
| 31 | + setItems([]); | |
| 32 | + setFailed(false); | |
| 33 | + } | |
| 34 | + return () => { | |
| 35 | + document.body.style.overflow = ''; | |
| 36 | + }; | |
| 37 | + }, [open]); | |
| 38 | + | |
| 39 | + useEffect(() => { | |
| 40 | + if (!open) return; | |
| 41 | + const term = q.trim(); | |
| 42 | + if (term.length < 1) { | |
| 43 | + setItems([]); | |
| 44 | + return; | |
| 45 | + } | |
| 46 | + const ctrl = new AbortController(); | |
| 47 | + const t = setTimeout(async () => { | |
| 48 | + setLoading(true); | |
| 49 | + try { | |
| 50 | + const res = await clientApi.suggest(term, ctrl.signal); | |
| 51 | + setItems(res.items ?? []); | |
| 52 | + setFailed(false); | |
| 53 | + setActive(0); | |
| 54 | + } catch (e) { | |
| 55 | + if ((e as Error).name !== 'AbortError') setFailed(true); | |
| 56 | + } finally { | |
| 57 | + setLoading(false); | |
| 58 | + } | |
| 59 | + }, 120); | |
| 60 | + return () => { | |
| 61 | + clearTimeout(t); | |
| 62 | + ctrl.abort(); | |
| 63 | + }; | |
| 64 | + }, [q, open]); | |
| 65 | + | |
| 66 | + if (!open) return null; | |
| 67 | + const rows: { key: string; href: string; node: React.ReactNode }[] = items.map((s) => ({ | |
| 68 | + key: s.id, | |
| 69 | + href: routes.entity(s), | |
| 70 | + node: ( | |
| 71 | + <> | |
| 72 | + <EntityBadge type={s.entity_type} small /> | |
| 73 | + <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span> | |
| 74 | + {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>} | |
| 75 | + </> | |
| 76 | + ), | |
| 77 | + })); | |
| 78 | + const term = q.trim(); | |
| 79 | + const allRows = term ? [...rows, { key: '__all', href: routes.search(term), node: (<><Search className="size-4 text-accent" aria-hidden /><span className="flex-1 text-sm text-accent">Search everything for “{term}”</span><CornerDownLeft className="size-3.5 text-ink-3" aria-hidden /></>) }] : rows; | |
| 80 | + | |
| 81 | + const go = (href: string) => { | |
| 82 | + router.push(href); | |
| 83 | + setOpen(false); | |
| 84 | + }; | |
| 85 | + const onKey = (e: React.KeyboardEvent) => { | |
| 86 | + if (e.key === 'ArrowDown') { | |
| 87 | + e.preventDefault(); | |
| 88 | + setActive((a) => Math.min(a + 1, allRows.length - 1)); | |
| 89 | + } else if (e.key === 'ArrowUp') { | |
| 90 | + e.preventDefault(); | |
| 91 | + setActive((a) => Math.max(a - 1, 0)); | |
| 92 | + } else if (e.key === 'Enter') { | |
| 93 | + e.preventDefault(); | |
| 94 | + const it = allRows[active]; | |
| 95 | + if (it) go(it.href); | |
| 96 | + else if (term) go(routes.search(term)); | |
| 97 | + } else if (e.key === 'Escape') setOpen(false); | |
| 98 | + }; | |
| 99 | + | |
| 100 | + return ( | |
| 101 | + <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[12vh]" role="dialog" aria-modal="true" aria-label="Search AI Atlas" onClick={() => setOpen(false)}> | |
| 102 | + <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[70vh] md:w-[680px] md:rounded-lg" onClick={(e) => e.stopPropagation()}> | |
| 103 | + <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5"> | |
| 104 | + <Search className="size-5 shrink-0 text-ink-3" aria-hidden /> | |
| 105 | + <input | |
| 106 | + ref={inputRef} | |
| 107 | + value={q} | |
| 108 | + onChange={(e) => setQ(e.target.value)} | |
| 109 | + onKeyDown={onKey} | |
| 110 | + placeholder="Search models, companies, papers, benchmarks… or ask in plain English" | |
| 111 | + className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" | |
| 112 | + autoComplete="off" | |
| 113 | + spellCheck={false} | |
| 114 | + aria-label="Search" | |
| 115 | + aria-activedescendant={allRows[active] ? `sugg-${allRows[active].key}` : undefined} | |
| 116 | + /> | |
| 117 | + <button type="button" onClick={() => setOpen(false)} className="flex size-10 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close search"> | |
| 118 | + <X className="size-5" aria-hidden /> | |
| 119 | + </button> | |
| 120 | + </div> | |
| 121 | + <div className="scrollbar-thin flex-1 overflow-y-auto"> | |
| 122 | + {term === '' ? ( | |
| 123 | + <div className="px-4 py-4"> | |
| 124 | + <p className="eyebrow mb-2">Try asking</p> | |
| 125 | + <ul className="flex flex-wrap gap-2"> | |
| 126 | + {EXAMPLE_QUERIES.map((ex) => ( | |
| 127 | + <li key={ex}> | |
| 128 | + <button type="button" onClick={() => setQ(ex)} className="border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink"> | |
| 129 | + {ex} | |
| 130 | + </button> | |
| 131 | + </li> | |
| 132 | + ))} | |
| 133 | + </ul> | |
| 134 | + <p className="eyebrow mt-6 mb-2">Browse</p> | |
| 135 | + <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-3"> | |
| 136 | + {exploreNav.map((n) => ( | |
| 137 | + <li key={n.href}> | |
| 138 | + <Link href={n.href} onClick={() => setOpen(false)} className="flex h-10 items-center gap-2 text-sm text-ink-2 hover:text-accent"> | |
| 139 | + <ArrowRight className="size-3.5" aria-hidden /> {n.label} | |
| 140 | + </Link> | |
| 141 | + </li> | |
| 142 | + ))} | |
| 143 | + </ul> | |
| 144 | + <p className="mt-6 text-xs text-ink-3"> | |
| 145 | + <kbd className="mono border border-rule px-1">↵</kbd> full results · <kbd className="mono border border-rule px-1">esc</kbd> close · <kbd className="mono border border-rule px-1">⌘K</kbd> anywhere | |
| 146 | + </p> | |
| 147 | + </div> | |
| 148 | + ) : ( | |
| 149 | + <ul className="py-1" role="listbox"> | |
| 150 | + {failed && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>} | |
| 151 | + {!loading && !failed && rows.length === 0 && <li className="px-4 py-3 text-sm text-ink-3">No direct match — search everything below.</li>} | |
| 152 | + {allRows.map((r, i) => ( | |
| 153 | + <li key={r.key} id={`sugg-${r.key}`} role="option" aria-selected={i === active}> | |
| 154 | + <Link href={r.href} onClick={() => setOpen(false)} onMouseEnter={() => setActive(i)} className={cn('flex min-h-[46px] items-center gap-3 px-4 py-2', i === active ? 'bg-surface-3' : 'hover:bg-surface-2')}> | |
| 155 | + {r.node} | |
| 156 | + </Link> | |
| 157 | + </li> | |
| 158 | + ))} | |
| 159 | + </ul> | |
| 160 | + )} | |
| 161 | + </div> | |
| 162 | + </div> | |
| 163 | + </div> | |
| 164 | + ); | |
| 165 | +} | |
added
apps/web/src/components/layout/site-footer.tsx
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { LogoMark } from '@/components/brand/logo'; | |
| 3 | +import { CONTACT_EMAIL, exploreNav, routes } from '@/lib/site'; | |
| 4 | + | |
| 5 | +export function SiteFooter() { | |
| 6 | + return ( | |
| 7 | + <footer className="mt-16 border-t border-rule"> | |
| 8 | + <div className="container-x mx-auto max-w-[1600px] py-10"> | |
| 9 | + <div className="grid gap-8 md:grid-cols-[1.4fr_1fr_1fr_1fr]"> | |
| 10 | + <div> | |
| 11 | + <p className="flex items-center gap-2 text-ink"> | |
| 12 | + <LogoMark size={20} /> <span className="font-semibold">AI Atlas</span> | |
| 13 | + </p> | |
| 14 | + <p className="mt-3 max-w-sm text-sm leading-relaxed text-ink-2"> | |
| 15 | + A continuously updated, source-attributed map of the global AI ecosystem. Every number links back to its source, tier and observation time; history is never discarded. | |
| 16 | + </p> | |
| 17 | + </div> | |
| 18 | + <div> | |
| 19 | + <p className="eyebrow mb-2">Explore</p> | |
| 20 | + <ul className="space-y-1 text-sm"> | |
| 21 | + {exploreNav.map((n) => ( | |
| 22 | + <li key={n.href}> | |
| 23 | + <Link href={n.href} className="inline-block py-0.5 text-ink-2 hover:text-ink"> | |
| 24 | + {n.label} | |
| 25 | + </Link> | |
| 26 | + </li> | |
| 27 | + ))} | |
| 28 | + </ul> | |
| 29 | + </div> | |
| 30 | + <div> | |
| 31 | + <p className="eyebrow mb-2">Track</p> | |
| 32 | + <ul className="space-y-1 text-sm"> | |
| 33 | + <li><Link href={routes.changes()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Changes</Link></li> | |
| 34 | + <li><Link href={routes.timeline()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Timeline</Link></li> | |
| 35 | + <li><Link href={routes.compare()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Compare</Link></li> | |
| 36 | + <li><Link href={routes.search('')} className="inline-block py-0.5 text-ink-2 hover:text-ink">Search</Link></li> | |
| 37 | + </ul> | |
| 38 | + </div> | |
| 39 | + <div> | |
| 40 | + <p className="eyebrow mb-2">About</p> | |
| 41 | + <ul className="space-y-1 text-sm"> | |
| 42 | + <li><Link href={routes.about()} className="inline-block py-0.5 text-ink-2 hover:text-ink">About</Link></li> | |
| 43 | + <li><Link href={routes.methodology()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Methodology</Link></li> | |
| 44 | + <li><Link href={routes.sources()} className="inline-block py-0.5 text-ink-2 hover:text-ink">Sources</Link></li> | |
| 45 | + <li><Link href={routes.developers()} className="inline-block py-0.5 text-ink-2 hover:text-ink">API / Developers</Link></li> | |
| 46 | + <li><Link href={routes.bot()} className="inline-block py-0.5 text-ink-2 hover:text-ink">AIAtlasBot</Link></li> | |
| 47 | + <li><a href={`mailto:${CONTACT_EMAIL}`} className="inline-block py-0.5 text-ink-2 hover:text-ink">Contact</a></li> | |
| 48 | + </ul> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + <div className="mt-10 flex flex-col gap-2 border-t border-rule pt-5 text-xs text-ink-3 md:flex-row md:items-center md:justify-between"> | |
| 52 | + <p>© 2026 Simon-Pierre Boucher · AI Atlas. Data is aggregated from public sources and attributed on every page.</p> | |
| 53 | + <p> | |
| 54 | + Hosted on{' '} | |
| 55 | + <a href="https://www.maclustr.io" className="text-ink-2 hover:text-ink" rel="noopener noreferrer"> | |
| 56 | + MacLustr | |
| 57 | + </a> | |
| 58 | + </p> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </footer> | |
| 62 | + ); | |
| 63 | +} | |
added
apps/web/src/components/layout/site-header.tsx
+139 −0
@@ -0,0 +1,139 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ChevronDown, Menu, Search, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { usePathname } from 'next/navigation'; | |
| 5 | +import { useEffect, useRef, useState } from 'react'; | |
| 6 | +import { Wordmark } from '@/components/brand/logo'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { exploreNav, moreNav, primaryNav, routes } from '@/lib/site'; | |
| 9 | +import { useOpenSearch } from './search-context'; | |
| 10 | +import { ThemeToggle } from './theme'; | |
| 11 | + | |
| 12 | +const EXPLORE_PREFIXES = exploreNav.map((n) => n.href); | |
| 13 | + | |
| 14 | +export function SiteHeader() { | |
| 15 | + const pathname = usePathname(); | |
| 16 | + const openSearch = useOpenSearch(); | |
| 17 | + const [menu, setMenu] = useState(false); | |
| 18 | + const [explore, setExplore] = useState(false); | |
| 19 | + const exploreRef = useRef<HTMLDivElement>(null); | |
| 20 | + useEffect(() => { | |
| 21 | + setMenu(false); | |
| 22 | + setExplore(false); | |
| 23 | + }, [pathname]); | |
| 24 | + useEffect(() => { | |
| 25 | + if (!explore) return; | |
| 26 | + const onDoc = (e: MouseEvent) => { | |
| 27 | + if (!exploreRef.current?.contains(e.target as Node)) setExplore(false); | |
| 28 | + }; | |
| 29 | + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setExplore(false); | |
| 30 | + document.addEventListener('mousedown', onDoc); | |
| 31 | + document.addEventListener('keydown', onKey); | |
| 32 | + return () => { | |
| 33 | + document.removeEventListener('mousedown', onDoc); | |
| 34 | + document.removeEventListener('keydown', onKey); | |
| 35 | + }; | |
| 36 | + }, [explore]); | |
| 37 | + | |
| 38 | + const exploreActive = pathname === '/explore' || EXPLORE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + '/')); | |
| 39 | + const navCls = (active: boolean) => cn('flex h-9 items-center gap-1 rounded-sm px-2.5 text-[13.5px] transition-colors', active ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink'); | |
| 40 | + | |
| 41 | + return ( | |
| 42 | + <header className="sticky top-0 z-40 border-b border-rule bg-canvas/85 backdrop-blur-md"> | |
| 43 | + <div className="container-x mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-3"> | |
| 44 | + <Link href={routes.home()} className="flex h-11 items-center" aria-label="AI Atlas home"> | |
| 45 | + <Wordmark /> | |
| 46 | + </Link> | |
| 47 | + <nav aria-label="Primary" className="ml-3 hidden items-center gap-0.5 lg:flex"> | |
| 48 | + <div className="relative" ref={exploreRef}> | |
| 49 | + <button type="button" onClick={() => setExplore((v) => !v)} aria-expanded={explore} aria-haspopup="menu" className={navCls(exploreActive)}> | |
| 50 | + Explore <ChevronDown className={cn('size-3.5 transition-transform', explore && 'rotate-180')} aria-hidden /> | |
| 51 | + </button> | |
| 52 | + {explore && ( | |
| 53 | + <div role="menu" className="panel absolute left-0 top-full mt-1 w-[520px] p-2 shadow-lg"> | |
| 54 | + <ul className="grid grid-cols-2 gap-0.5"> | |
| 55 | + {exploreNav.map((n) => ( | |
| 56 | + <li key={n.href}> | |
| 57 | + <Link role="menuitem" href={n.href} className={cn('block rounded-sm px-3 py-2 text-sm hover:bg-surface-2', pathname.startsWith(n.href) ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink')}> | |
| 58 | + {n.label} | |
| 59 | + </Link> | |
| 60 | + </li> | |
| 61 | + ))} | |
| 62 | + <li className="col-span-2 mt-1 border-t border-rule pt-1"> | |
| 63 | + <Link role="menuitem" href={routes.explore()} className="block rounded-sm px-3 py-2 text-sm text-accent hover:bg-surface-2"> | |
| 64 | + All entity types with live counts → | |
| 65 | + </Link> | |
| 66 | + </li> | |
| 67 | + </ul> | |
| 68 | + </div> | |
| 69 | + )} | |
| 70 | + </div> | |
| 71 | + {primaryNav.map((n) => { | |
| 72 | + const active = pathname === n.href || pathname.startsWith(n.href + '/'); | |
| 73 | + return ( | |
| 74 | + <Link key={n.href} href={n.href} aria-current={active ? 'page' : undefined} className={navCls(active)}> | |
| 75 | + {n.label} | |
| 76 | + </Link> | |
| 77 | + ); | |
| 78 | + })} | |
| 79 | + </nav> | |
| 80 | + <div className="ml-auto flex items-center gap-1"> | |
| 81 | + <button type="button" onClick={openSearch} className="hidden h-9 min-w-[280px] items-center gap-2 rounded-sm border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex" aria-label="Open search"> | |
| 82 | + <Search className="size-4" aria-hidden /> | |
| 83 | + <span className="flex-1 text-left">Search the AI ecosystem…</span> | |
| 84 | + <kbd className="mono rounded-[3px] border border-rule px-1.5 py-0.5 text-[10px]">⌘K</kbd> | |
| 85 | + </button> | |
| 86 | + <button type="button" onClick={openSearch} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:hidden" aria-label="Open search"> | |
| 87 | + <Search className="size-5" aria-hidden /> | |
| 88 | + </button> | |
| 89 | + <ThemeToggle /> | |
| 90 | + <button type="button" onClick={() => setMenu((m) => !m)} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink lg:hidden" aria-label={menu ? 'Close menu' : 'Open menu'} aria-expanded={menu}> | |
| 91 | + {menu ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />} | |
| 92 | + </button> | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + {menu && ( | |
| 96 | + <div className="border-t border-rule bg-canvas lg:hidden"> | |
| 97 | + <div className="container-x mx-auto grid max-w-[1600px] gap-6 py-5 sm:grid-cols-3"> | |
| 98 | + <div> | |
| 99 | + <p className="eyebrow mb-2">Explore</p> | |
| 100 | + <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 101 | + {exploreNav.map((n) => ( | |
| 102 | + <li key={n.href}> | |
| 103 | + <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 104 | + {n.label} | |
| 105 | + </Link> | |
| 106 | + </li> | |
| 107 | + ))} | |
| 108 | + </ul> | |
| 109 | + </div> | |
| 110 | + <div> | |
| 111 | + <p className="eyebrow mb-2">Track</p> | |
| 112 | + <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 113 | + {primaryNav.map((n) => ( | |
| 114 | + <li key={n.href}> | |
| 115 | + <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 116 | + {n.label} | |
| 117 | + </Link> | |
| 118 | + </li> | |
| 119 | + ))} | |
| 120 | + </ul> | |
| 121 | + </div> | |
| 122 | + <div> | |
| 123 | + <p className="eyebrow mb-2">About</p> | |
| 124 | + <ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-1"> | |
| 125 | + {moreNav.map((n) => ( | |
| 126 | + <li key={n.href}> | |
| 127 | + <Link href={n.href} className="block py-2.5 text-[15px] text-ink-2 hover:text-ink"> | |
| 128 | + {n.label} | |
| 129 | + </Link> | |
| 130 | + </li> | |
| 131 | + ))} | |
| 132 | + </ul> | |
| 133 | + </div> | |
| 134 | + </div> | |
| 135 | + </div> | |
| 136 | + )} | |
| 137 | + </header> | |
| 138 | + ); | |
| 139 | +} | |
added
apps/web/src/components/layout/theme.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Monitor, Moon, Sun } from 'lucide-react'; | |
| 3 | +import { useEffect, useState } from 'react'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | + | |
| 6 | +export type ThemePref = 'light' | 'dark' | 'system'; | |
| 7 | +const KEY = 'aia-theme'; | |
| 8 | + | |
| 9 | +/** Inline in <head> before paint: applies the persisted preference (or the system scheme) to <html data-theme>. */ | |
| 10 | +export const THEME_SCRIPT = `(function(){try{var k='${KEY}',p=localStorage.getItem(k),m=window.matchMedia('(prefers-color-scheme: dark)'),t=(p==='light'||p==='dark')?p:(m.matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;}catch(e){}})();`; | |
| 11 | + | |
| 12 | +function apply(pref: ThemePref) { | |
| 13 | + const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 14 | + const t = pref === 'system' ? (dark ? 'dark' : 'light') : pref; | |
| 15 | + document.documentElement.setAttribute('data-theme', t); | |
| 16 | + document.documentElement.style.colorScheme = t; | |
| 17 | + const meta = document.querySelector('meta[name="theme-color"]'); | |
| 18 | + if (meta) meta.setAttribute('content', t === 'dark' ? '#0b0d11' : '#f6f6f3'); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function useTheme(): [ThemePref, (p: ThemePref) => void] { | |
| 22 | + const [pref, setPref] = useState<ThemePref>('system'); | |
| 23 | + useEffect(() => { | |
| 24 | + const p = localStorage.getItem(KEY); | |
| 25 | + if (p === 'light' || p === 'dark') setPref(p); | |
| 26 | + // Error/not-found shells are client-rendered from scratch and lose the attribute set by THEME_SCRIPT: re-apply. | |
| 27 | + if (!document.documentElement.getAttribute('data-theme')) apply(p === 'light' || p === 'dark' ? p : 'system'); | |
| 28 | + const m = window.matchMedia('(prefers-color-scheme: dark)'); | |
| 29 | + const onChange = () => { | |
| 30 | + const cur = localStorage.getItem(KEY); | |
| 31 | + if (cur !== 'light' && cur !== 'dark') apply('system'); | |
| 32 | + }; | |
| 33 | + m.addEventListener('change', onChange); | |
| 34 | + return () => m.removeEventListener('change', onChange); | |
| 35 | + }, []); | |
| 36 | + const set = (p: ThemePref) => { | |
| 37 | + setPref(p); | |
| 38 | + if (p === 'system') localStorage.removeItem(KEY); | |
| 39 | + else localStorage.setItem(KEY, p); | |
| 40 | + apply(p); | |
| 41 | + }; | |
| 42 | + return [pref, set]; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Three-state toggle (system → light → dark). Touch target 44 px. */ | |
| 46 | +export function ThemeToggle({ className }: { className?: string }) { | |
| 47 | + const [pref, set] = useTheme(); | |
| 48 | + const next: ThemePref = pref === 'system' ? 'light' : pref === 'light' ? 'dark' : 'system'; | |
| 49 | + const Icon = pref === 'system' ? Monitor : pref === 'light' ? Sun : Moon; | |
| 50 | + const label = pref === 'system' ? 'Theme: system' : pref === 'light' ? 'Theme: light' : 'Theme: dark'; | |
| 51 | + return ( | |
| 52 | + <button type="button" onClick={() => set(next)} className={cn('flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:size-10', className)} aria-label={`${label} — switch`} title={label}> | |
| 53 | + <Icon className="size-[18px]" aria-hidden /> | |
| 54 | + </button> | |
| 55 | + ); | |
| 56 | +} | |
added
apps/web/src/components/layout/view-beacon.tsx
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useEffect } from 'react'; | |
| 3 | +import { clientApi } from '@/lib/client-api'; | |
| 4 | + | |
| 5 | +/** Fire-and-forget page-view beacon (POST /views { path }) once per entity page mount. Renders nothing. */ | |
| 6 | +export function ViewBeacon({ path }: { path: string }) { | |
| 7 | + useEffect(() => { | |
| 8 | + const t = setTimeout(() => void clientApi.view(path), 800); | |
| 9 | + return () => clearTimeout(t); | |
| 10 | + }, [path]); | |
| 11 | + return null; | |
| 12 | +} | |
added
apps/web/src/components/listing/filters.tsx
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +import { SlidersHorizontal } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import type { ReactNode } from 'react'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { fmtInt } from '@/lib/format'; | |
| 6 | + | |
| 7 | +/* | |
| 8 | + URL-driven listing filters (server components, no JS required): a GET <form> whose fields map 1:1 to query params. | |
| 9 | + Facets are rendered as links that patch the URL. On mobile the facet sidebar becomes a <details> sheet. | |
| 10 | +*/ | |
| 11 | + | |
| 12 | +export type FilterField = | |
| 13 | + | { kind: 'select'; name: string; label: string; options: { value: string; label: string }[]; value?: string } | |
| 14 | + | { kind: 'text'; name: string; label: string; value?: string; placeholder?: string; inputMode?: 'numeric' | 'text' } | |
| 15 | + | { kind: 'hidden'; name: string; value: string }; | |
| 16 | + | |
| 17 | +export function FilterBar({ action, fields, sort, className, submitLabel = 'Apply', resetHref }: { action: string; fields: FilterField[]; sort?: { value?: string; options: { value: string; label: string }[] }; className?: string; submitLabel?: string; resetHref?: string }) { | |
| 18 | + const cls = 'h-10 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none'; | |
| 19 | + return ( | |
| 20 | + <form action={action} method="get" className={cn('grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6', className)}> | |
| 21 | + {fields.map((f) => | |
| 22 | + f.kind === 'hidden' ? ( | |
| 23 | + <input key={f.name} type="hidden" name={f.name} value={f.value} /> | |
| 24 | + ) : ( | |
| 25 | + <label key={f.name} className="block min-w-0"> | |
| 26 | + <span className="eyebrow block pb-1">{f.label}</span> | |
| 27 | + {f.kind === 'select' ? ( | |
| 28 | + <select name={f.name} defaultValue={f.value ?? ''} className={cls}> | |
| 29 | + <option value="">Any</option> | |
| 30 | + {f.options.map((o) => ( | |
| 31 | + <option key={o.value} value={o.value}> | |
| 32 | + {o.label} | |
| 33 | + </option> | |
| 34 | + ))} | |
| 35 | + </select> | |
| 36 | + ) : ( | |
| 37 | + <input name={f.name} defaultValue={f.value ?? ''} placeholder={f.placeholder} inputMode={f.inputMode} className={cls} /> | |
| 38 | + )} | |
| 39 | + </label> | |
| 40 | + ), | |
| 41 | + )} | |
| 42 | + {sort && ( | |
| 43 | + <label className="block min-w-0"> | |
| 44 | + <span className="eyebrow block pb-1">Sort</span> | |
| 45 | + <select name="sort" defaultValue={sort.value ?? ''} className={cls}> | |
| 46 | + {sort.options.map((o) => ( | |
| 47 | + <option key={o.value} value={o.value}> | |
| 48 | + {o.label} | |
| 49 | + </option> | |
| 50 | + ))} | |
| 51 | + </select> | |
| 52 | + </label> | |
| 53 | + )} | |
| 54 | + <div className="flex items-end gap-2"> | |
| 55 | + <button type="submit" className="inline-flex h-10 flex-1 items-center justify-center gap-1.5 bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90"> | |
| 56 | + <SlidersHorizontal className="size-4" aria-hidden /> {submitLabel} | |
| 57 | + </button> | |
| 58 | + {resetHref && ( | |
| 59 | + <Link href={resetHref} className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink"> | |
| 60 | + Reset | |
| 61 | + </Link> | |
| 62 | + )} | |
| 63 | + </div> | |
| 64 | + </form> | |
| 65 | + ); | |
| 66 | +} | |
| 67 | + | |
| 68 | +export type FacetGroup = { key: string; label: string; items: { value: string; label?: string; count: number | string | null }[] }; | |
| 69 | + | |
| 70 | +/** Facet list: each value links to the current URL with the facet applied (or removed when active). */ | |
| 71 | +export function Facets({ groups, current, makeHref, className }: { groups: FacetGroup[]; current: Record<string, string | undefined>; makeHref: (patch: Record<string, string | undefined>) => string; className?: string }) { | |
| 72 | + const shown = groups.filter((g) => g.items.length); | |
| 73 | + if (!shown.length) return null; | |
| 74 | + return ( | |
| 75 | + <div className={cn('space-y-6', className)}> | |
| 76 | + {shown.map((g) => ( | |
| 77 | + <div key={g.key}> | |
| 78 | + <p className="eyebrow mb-1.5">{g.label}</p> | |
| 79 | + <ul className="space-y-px"> | |
| 80 | + {g.items.slice(0, 12).map((it) => { | |
| 81 | + const on = current[g.key] === it.value; | |
| 82 | + return ( | |
| 83 | + <li key={it.value}> | |
| 84 | + <Link href={makeHref({ [g.key]: on ? undefined : it.value, offset: undefined })} className={cn('flex min-h-8 items-center justify-between gap-2 px-1 text-sm hover:bg-surface-2', on ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2')} aria-current={on ? 'true' : undefined}> | |
| 85 | + <span className="truncate">{it.label ?? it.value}</span> | |
| 86 | + <span className="tnum shrink-0 text-xs text-ink-3">{fmtInt(it.count)}</span> | |
| 87 | + </Link> | |
| 88 | + </li> | |
| 89 | + ); | |
| 90 | + })} | |
| 91 | + </ul> | |
| 92 | + </div> | |
| 93 | + ))} | |
| 94 | + </div> | |
| 95 | + ); | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** Two-column listing layout: facets aside (desktop) / <details> sheet (mobile) + content. */ | |
| 99 | +export function ListingLayout({ facets, children }: { facets: ReactNode; children: ReactNode }) { | |
| 100 | + return ( | |
| 101 | + <div className="grid gap-8 lg:grid-cols-[14rem_minmax(0,1fr)]"> | |
| 102 | + <aside className="min-w-0"> | |
| 103 | + <details className="lg:hidden"> | |
| 104 | + <summary className="flex h-10 cursor-pointer list-none items-center gap-2 border border-rule px-3 text-sm text-ink-2 [&::-webkit-details-marker]:hidden"> | |
| 105 | + <SlidersHorizontal className="size-4" aria-hidden /> Facets | |
| 106 | + </summary> | |
| 107 | + <div className="border-x border-b border-rule p-3">{facets}</div> | |
| 108 | + </details> | |
| 109 | + <div className="hidden lg:block">{facets}</div> | |
| 110 | + </aside> | |
| 111 | + <div className="min-w-0">{children}</div> | |
| 112 | + </div> | |
| 113 | + ); | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function ActiveFilters({ current, labels, makeHref, className }: { current: Record<string, string | undefined>; labels: Record<string, string>; makeHref: (patch: Record<string, string | undefined>) => string; className?: string }) { | |
| 117 | + const active = Object.entries(current).filter(([k, v]) => v && k !== 'offset' && k !== 'sort' && k !== 'order'); | |
| 118 | + if (!active.length) return null; | |
| 119 | + return ( | |
| 120 | + <ul className={cn('flex flex-wrap gap-1.5', className)}> | |
| 121 | + {active.map(([k, v]) => ( | |
| 122 | + <li key={k}> | |
| 123 | + <Link href={makeHref({ [k]: undefined, offset: undefined })} className="inline-flex h-7 items-center gap-1 border border-rule bg-surface px-2 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" title="Remove filter"> | |
| 124 | + <span className="text-ink-3">{labels[k] ?? k}</span> {v} <span aria-hidden>×</span> | |
| 125 | + </Link> | |
| 126 | + </li> | |
| 127 | + ))} | |
| 128 | + </ul> | |
| 129 | + ); | |
| 130 | +} | |
added
apps/web/src/components/listing/generic-listing.tsx
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | +import { FilterBar } from '@/components/listing/filters'; | |
| 3 | +import { EntityRow } from '@/components/ui/entity'; | |
| 4 | +import { Pagination, withParams } from '@/components/ui/pagination'; | |
| 5 | +import { Container, PageHeader } from '@/components/ui/section'; | |
| 6 | +import { EmptyState, Unavailable } from '@/components/ui/unavailable'; | |
| 7 | +import { api, safe } from '@/lib/api'; | |
| 8 | +import { fmtInt } from '@/lib/format'; | |
| 9 | +import type { EntitySummary, Page } from '@/lib/types'; | |
| 10 | + | |
| 11 | +const LIMIT = 40; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>; | |
| 15 | + * pass a custom fetcher for typed endpoints (/papers, /hardware). Renders `EntityRow`s — dense, mobile-safe. | |
| 16 | + */ | |
| 17 | +export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode }) { | |
| 18 | + const current: Record<string, string | undefined> = {}; | |
| 19 | + for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k]; | |
| 20 | + const offset = Math.max(0, Number(current.offset) || 0); | |
| 21 | + const query = { ...current, limit: LIMIT, offset }; | |
| 22 | + const page = await safe(fetch ? fetch(query) : api.explore(type, query)); | |
| 23 | + const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch); | |
| 24 | + return ( | |
| 25 | + <Container> | |
| 26 | + <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p> : undefined}> | |
| 27 | + <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...extraFields]} sort={{ value: current.sort ?? sorts[0]?.value, options: sorts }} /> | |
| 28 | + </PageHeader> | |
| 29 | + {children} | |
| 30 | + <div className="pb-16"> | |
| 31 | + {!page ? ( | |
| 32 | + <Unavailable what={title} /> | |
| 33 | + ) : page.items.length === 0 ? ( | |
| 34 | + <EmptyState title={`No ${title.toLowerCase()} match`}>Try another name or remove filters.</EmptyState> | |
| 35 | + ) : ( | |
| 36 | + <> | |
| 37 | + <ul className="border-t border-rule"> | |
| 38 | + {page.items.map((e) => ( | |
| 39 | + <EntityRow key={e.id} e={e} showType={e.entity_type !== type} /> | |
| 40 | + ))} | |
| 41 | + </ul> | |
| 42 | + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> | |
| 43 | + </> | |
| 44 | + )} | |
| 45 | + </div> | |
| 46 | + </Container> | |
| 47 | + ); | |
| 48 | +} | |
added
apps/web/src/components/meta/sitemap-data.ts
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { api, safe } from '@/lib/api'; | |
| 3 | +import { routes, SITE_URL, TYPE_PATH } from '@/lib/site'; | |
| 4 | + | |
| 5 | +export const SITEMAP_HEADERS = { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, s-maxage=3600, stale-while-revalidate=86400' }; | |
| 6 | +export const SHARD = 5000; | |
| 7 | +const STATIC = ['/', '/models', '/companies', '/papers', '/providers', '/benchmarks', '/hardware', '/frameworks', '/datasets', '/tools', '/changes', '/timeline', '/compare', '/explore', '/methodology', '/sources', '/about', '/developers', '/bot']; | |
| 8 | +/** Types with their own sitemap series (in this order); everything else goes into the "other" series. */ | |
| 9 | +const SERIES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'dataset', 'tool', 'repository'] as const; | |
| 10 | + | |
| 11 | +function esc(s: string): string { | |
| 12 | + return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function toIndex(locs: string[]): string { | |
| 16 | + return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${locs.map((l) => ` <sitemap><loc>${esc(SITE_URL + l)}</loc></sitemap>`).join('\n')}\n</sitemapindex>\n`; | |
| 17 | +} | |
| 18 | +export function toUrlset(entries: { loc: string; lastmod?: string }[]): string { | |
| 19 | + return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => ` <url><loc>${esc(SITE_URL + e.loc)}</loc>${e.lastmod ? `<lastmod>${e.lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Shard ids: `static`, then `<type>-<n>` per type with entities. */ | |
| 23 | +export async function shardIds(): Promise<string[]> { | |
| 24 | + const ids = ['static']; | |
| 25 | + const stats = await safe(api.stats()); | |
| 26 | + for (const t of SERIES) { | |
| 27 | + const n = Number(stats?.entities?.[t] ?? 0); | |
| 28 | + const shards = n > 0 ? Math.ceil(n / SHARD) : 0; | |
| 29 | + for (let i = 0; i < shards; i++) ids.push(`${t}-${i}`); | |
| 30 | + } | |
| 31 | + return ids; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export async function shardEntries(id: string): Promise<{ loc: string; lastmod?: string }[] | null> { | |
| 35 | + if (id === 'static') return STATIC.map((loc) => ({ loc })); | |
| 36 | + const m = /^([a-z_]+)-(\d+)$/.exec(id); | |
| 37 | + if (!m) return null; | |
| 38 | + const type = m[1] as string; | |
| 39 | + const n = Number(m[2]); | |
| 40 | + if (!(SERIES as readonly string[]).includes(type) || !TYPE_PATH[type]) return null; | |
| 41 | + const res = await safe(api.sitemap(type, SHARD, n * SHARD)); | |
| 42 | + if (!res) return []; | |
| 43 | + return res.items.map((it) => ({ loc: routes.entity({ entity_type: it.entity_type, slug: it.slug }), lastmod: it.updated_at })); | |
| 44 | +} | |
added
apps/web/src/components/ui/badges.tsx
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +import { cn } from '@/lib/cn'; | |
| 2 | +import { IMPORTANCE_LABELS, OPENNESS_LABELS, STATUS_LABELS, TIER_LABELS, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; | |
| 3 | + | |
| 4 | +const base = 'inline-flex items-center gap-1 whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide'; | |
| 5 | + | |
| 6 | +/** Entity-type badge: coloured text + faint tint, letter-spaced. `small` variant for dense feeds. */ | |
| 7 | +export function EntityBadge({ type, className, small = false }: { type: string; className?: string; small?: boolean }) { | |
| 8 | + const key = TYPE_COLOR_KEY[type] ?? 'tool'; | |
| 9 | + return ( | |
| 10 | + <span className={cn(base, small && 'px-1 text-[10px]', 'uppercase', className)} style={{ color: `var(--type-${key})`, background: `color-mix(in srgb, var(--type-${key}) 10%, transparent)` }}> | |
| 11 | + {typeLabel(type)} | |
| 12 | + </span> | |
| 13 | + ); | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Source tier 1–4 (official → unverified). */ | |
| 17 | +export function TierBadge({ tier, className, withLabel = false }: { tier: number | null | undefined; className?: string; withLabel?: boolean }) { | |
| 18 | + if (tier === null || tier === undefined) return <span className={cn(base, 'text-ink-3', className)}>tier —</span>; | |
| 19 | + const t = Math.min(4, Math.max(1, Math.round(tier))); | |
| 20 | + return ( | |
| 21 | + <span className={cn(base, 'mono', className)} style={{ color: `var(--tier-${t})`, background: `color-mix(in srgb, var(--tier-${t}) 12%, transparent)` }} title={TIER_LABELS[t]}> | |
| 22 | + T{t} | |
| 23 | + {withLabel && <span className="font-sans normal-case tracking-normal">· {TIER_LABELS[t]}</span>} | |
| 24 | + </span> | |
| 25 | + ); | |
| 26 | +} | |
| 27 | + | |
| 28 | +const CONF: Record<string, string> = { verified: 'text-positive bg-positive-soft', high: 'text-positive bg-positive-soft', medium: 'text-accent bg-accent-soft', low: 'text-warning bg-warning-soft', conflicted: 'text-danger bg-danger-soft' }; | |
| 29 | +export function ConfidenceBadge({ confidence, className }: { confidence: string | null | undefined; className?: string }) { | |
| 30 | + if (!confidence) return null; | |
| 31 | + return <span className={cn(base, CONF[confidence] ?? 'text-ink-3 bg-surface-2', className)}>{confidence}</span>; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const STATUS: Record<string, string> = { | |
| 35 | + active: 'text-positive bg-positive-soft', | |
| 36 | + preview: 'text-accent bg-accent-soft', | |
| 37 | + announced: 'text-accent bg-accent-soft', | |
| 38 | + 'limited-availability': 'text-warning bg-warning-soft', | |
| 39 | + deprecated: 'text-warning bg-warning-soft', | |
| 40 | + retired: 'text-danger bg-danger-soft', | |
| 41 | +}; | |
| 42 | +export function StatusBadge({ status, className }: { status: string | null | undefined; className?: string }) { | |
| 43 | + if (!status || status === 'unknown') return null; | |
| 44 | + return <span className={cn(base, STATUS[status] ?? 'text-ink-2 bg-surface-2', className)}>{STATUS_LABELS[status] ?? status}</span>; | |
| 45 | +} | |
| 46 | + | |
| 47 | +const OPEN: Record<string, string> = { 'open-weights': 'text-positive bg-positive-soft', 'open-source': 'text-positive bg-positive-soft', proprietary: 'text-ink-2 bg-surface-2', restricted: 'text-warning bg-warning-soft' }; | |
| 48 | +export function OpennessBadge({ openness, className }: { openness: string | null | undefined; className?: string }) { | |
| 49 | + if (!openness) return null; | |
| 50 | + return <span className={cn(base, OPEN[openness] ?? 'text-ink-2 bg-surface-2', className)}>{OPENNESS_LABELS[openness] ?? openness}</span>; | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Importance 0–3 as a small stepped meter (■■■□). */ | |
| 54 | +export function ImportanceMark({ importance, className }: { importance: number; className?: string }) { | |
| 55 | + const n = Math.min(3, Math.max(0, importance)); | |
| 56 | + return ( | |
| 57 | + <span className={cn('inline-flex items-center gap-[2px]', className)} title={`${IMPORTANCE_LABELS[n]} (importance ${n})`} aria-label={`Importance ${n} of 3`}> | |
| 58 | + {[1, 2, 3].map((i) => ( | |
| 59 | + <span key={i} className={cn('block h-2 w-[3px] rounded-[1px]', i <= n ? (n === 3 ? 'bg-accent-2' : 'bg-ink-2') : 'bg-rule-strong')} /> | |
| 60 | + ))} | |
| 61 | + </span> | |
| 62 | + ); | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** Generic neutral chip (tags, families, modalities). */ | |
| 66 | +export function Chip({ children, className, tone = 'neutral' }: { children: React.ReactNode; className?: string; tone?: 'neutral' | 'accent' | 'estimated' }) { | |
| 67 | + return ( | |
| 68 | + <span className={cn(base, tone === 'neutral' && 'bg-surface-2 text-ink-2', tone === 'accent' && 'bg-accent-soft text-accent', tone === 'estimated' && 'border border-dashed border-warning/60 text-warning', className)}>{children}</span> | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Derived/estimated label linking to methodology. */ | |
| 73 | +export function Estimated({ className }: { className?: string }) { | |
| 74 | + return ( | |
| 75 | + <a href="/methodology#estimates" className={cn(base, 'border border-dashed border-warning/60 uppercase text-warning hover:bg-warning-soft', className)}> | |
| 76 | + Estimated | |
| 77 | + </a> | |
| 78 | + ); | |
| 79 | +} | |
added
apps/web/src/components/ui/data-table.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +import type { ReactNode, TdHTMLAttributes, ThHTMLAttributes } from 'react'; | |
| 2 | +import { cn } from '@/lib/cn'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Data table helpers. `<DataTable stack>` turns rows into two-column stacked records under 768 px — give every <Td> | |
| 6 | + * a `label` (renders `data-label`) and mark the name cell `primary`. `<DataTable scroll>` keeps columns and scrolls | |
| 7 | + * horizontally instead (for numeric comparison tables where row structure matters). | |
| 8 | + */ | |
| 9 | +export function DataTable({ children, stack = true, scroll = false, compact = false, className, caption }: { children: ReactNode; stack?: boolean; scroll?: boolean; compact?: boolean; className?: string; caption?: string }) { | |
| 10 | + const table = ( | |
| 11 | + <table className={cn('data-table', stack && !scroll && 'stack', compact && 'compact', className)}> | |
| 12 | + {caption && <caption className="sr-only">{caption}</caption>} | |
| 13 | + {children} | |
| 14 | + </table> | |
| 15 | + ); | |
| 16 | + return scroll ? <div className="table-scroll -mx-4 px-4 md:mx-0 md:px-0">{table}</div> : table; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function Th({ children, num, className, ...rest }: ThHTMLAttributes<HTMLTableCellElement> & { num?: boolean }) { | |
| 20 | + return ( | |
| 21 | + <th scope="col" className={cn(num && 'num', className)} {...rest}> | |
| 22 | + {children} | |
| 23 | + </th> | |
| 24 | + ); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function Td({ children, label, num, primary, wide, hideStack, className, ...rest }: TdHTMLAttributes<HTMLTableCellElement> & { label?: string; num?: boolean; primary?: boolean; wide?: boolean; hideStack?: boolean }) { | |
| 28 | + return ( | |
| 29 | + <td data-label={label} className={cn(num && 'num', primary && 'primary', wide && 'wide', hideStack && 'hide-stack', className)} {...rest}> | |
| 30 | + {children} | |
| 31 | + </td> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function EmptyRow({ cols, children = 'No rows.' }: { cols: number; children?: ReactNode }) { | |
| 36 | + return ( | |
| 37 | + <tr> | |
| 38 | + <td colSpan={cols} className="py-8 text-center text-sm text-ink-3"> | |
| 39 | + {children} | |
| 40 | + </td> | |
| 41 | + </tr> | |
| 42 | + ); | |
| 43 | +} | |
added
apps/web/src/components/ui/entity.tsx
+164 −0
@@ -0,0 +1,164 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import type { ReactNode } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { fmtDate, fmtInt, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format'; | |
| 5 | +import { routes, typeLabel } from '@/lib/site'; | |
| 6 | +import type { EntitySummary } from '@/lib/types'; | |
| 7 | +import { EntityBadge, OpennessBadge, StatusBadge } from './badges'; | |
| 8 | + | |
| 9 | +export function EntityLink({ e, className, children }: { e: { entity_type: string; slug: string; name: string }; className?: string; children?: ReactNode }) { | |
| 10 | + return ( | |
| 11 | + <Link href={routes.entity(e)} className={cn('text-ink hover:text-accent hover:underline', className)}> | |
| 12 | + {children ?? e.name} | |
| 13 | + </Link> | |
| 14 | + ); | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Key attribute chips for a summary, type-aware (model: params · context · release; company: country; …). */ | |
| 18 | +export function keyAttributes(e: EntitySummary): { label: string; value: string }[] { | |
| 19 | + const a = e.attributes ?? {}; | |
| 20 | + const out: { label: string; value: string }[] = []; | |
| 21 | + const push = (label: string, value: string | null | undefined) => { | |
| 22 | + if (value && value !== '—') out.push({ label, value }); | |
| 23 | + }; | |
| 24 | + switch (e.entity_type) { | |
| 25 | + case 'model': | |
| 26 | + case 'quantization': { | |
| 27 | + const p = num(a.parameter_count); | |
| 28 | + const ap = num(a.active_parameter_count); | |
| 29 | + if (p !== null) push('params', ap !== null && ap !== p ? `${fmtParams(p)} (${fmtParams(ap)} active)` : fmtParams(p)); | |
| 30 | + if (num(a.context_length) !== null) push('context', fmtTokens(a.context_length)); | |
| 31 | + if (typeof a.release_date === 'string') push('released', fmtDate(a.release_date)); | |
| 32 | + if (typeof a.license === 'string') push('license', a.license); | |
| 33 | + break; | |
| 34 | + } | |
| 35 | + case 'company': | |
| 36 | + case 'organization': | |
| 37 | + case 'lab': | |
| 38 | + case 'university': | |
| 39 | + if (typeof a.country === 'string') push('country', a.country); | |
| 40 | + if (a.founded) push('founded', String(a.founded).slice(0, 4)); | |
| 41 | + if (typeof a.org_kind === 'string') push('kind', a.org_kind); | |
| 42 | + break; | |
| 43 | + case 'paper': | |
| 44 | + if (typeof a.published_at === 'string') push('published', fmtDate(a.published_at)); | |
| 45 | + if (Array.isArray(a.authors) && a.authors.length) push('authors', `${a.authors.length}`); | |
| 46 | + if (typeof a.venue === 'string') push('venue', a.venue); | |
| 47 | + if (typeof a.arxiv_id === 'string') push('arXiv', a.arxiv_id); | |
| 48 | + break; | |
| 49 | + case 'provider': | |
| 50 | + if (Array.isArray(a.regions) && a.regions.length) push('regions', `${a.regions.length}`); | |
| 51 | + break; | |
| 52 | + case 'benchmark': | |
| 53 | + if (typeof a.category === 'string') push('category', a.category); | |
| 54 | + if (typeof a.metric === 'string') push('metric', a.metric); | |
| 55 | + break; | |
| 56 | + case 'hardware': | |
| 57 | + case 'robot': | |
| 58 | + if (typeof a.kind === 'string') push('kind', a.kind); | |
| 59 | + if (num(a.memory_gb) !== null) push('memory', `${fmtInt(a.memory_gb)} GB`); | |
| 60 | + if (num(a.memory_bandwidth_gbs) !== null) push('bandwidth', `${fmtInt(a.memory_bandwidth_gbs)} GB/s`); | |
| 61 | + if (num(a.tdp_watts) !== null) push('TDP', `${fmtInt(a.tdp_watts)} W`); | |
| 62 | + break; | |
| 63 | + case 'framework': | |
| 64 | + case 'library': | |
| 65 | + case 'runtime': | |
| 66 | + case 'repository': | |
| 67 | + if (typeof a.latest_version === 'string') push('version', a.latest_version); | |
| 68 | + if (typeof a.language === 'string') push('language', a.language); | |
| 69 | + if (num(a['metric.stars']) !== null) push('stars', fmtInt(a['metric.stars'])); | |
| 70 | + if (typeof a.license === 'string') push('license', a.license); | |
| 71 | + break; | |
| 72 | + case 'dataset': | |
| 73 | + if (typeof a.modality === 'string') push('modality', a.modality); | |
| 74 | + if (a.size) push('size', String(a.size)); | |
| 75 | + if (typeof a.license === 'string') push('license', a.license); | |
| 76 | + break; | |
| 77 | + default: | |
| 78 | + break; | |
| 79 | + } | |
| 80 | + return out.slice(0, 4); | |
| 81 | +} | |
| 82 | + | |
| 83 | +export function cheapestPrice(e: EntitySummary): string | null { | |
| 84 | + const a = e.attributes ?? {}; | |
| 85 | + const v = num(a.min_input_per_mtok ?? a.best_input_per_mtok); | |
| 86 | + return v === null ? null : fmtUsdPerM(v); | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Dense list row: badge · name (link) · org · key attributes · quality. Used by search results, related, explore. */ | |
| 90 | +export function EntityRow({ e, showType = true, trailing, className, showDescription = true }: { e: EntitySummary; showType?: boolean; trailing?: ReactNode; className?: string; showDescription?: boolean }) { | |
| 91 | + const attrs = keyAttributes(e); | |
| 92 | + const openness = typeof e.attributes?.openness === 'string' ? e.attributes.openness : null; | |
| 93 | + return ( | |
| 94 | + <li className={cn('border-b border-rule py-3', className)}> | |
| 95 | + <div className="flex items-start gap-3"> | |
| 96 | + <div className="min-w-0 flex-1"> | |
| 97 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 98 | + {showType && <EntityBadge type={e.entity_type} small />} | |
| 99 | + <EntityLink e={e} className="text-[15px] font-medium" /> | |
| 100 | + {e.organization && ( | |
| 101 | + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="text-sm text-ink-3 hover:text-accent"> | |
| 102 | + {e.organization.name} | |
| 103 | + </Link> | |
| 104 | + )} | |
| 105 | + <StatusBadge status={e.status} /> | |
| 106 | + {openness && <OpennessBadge openness={openness} />} | |
| 107 | + </div> | |
| 108 | + {showDescription && e.description && <p className="mt-1 line-clamp-2 text-sm text-ink-2">{e.description}</p>} | |
| 109 | + {attrs.length > 0 && ( | |
| 110 | + <p className="tnum mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-ink-3"> | |
| 111 | + {attrs.map((x) => ( | |
| 112 | + <span key={x.label}> | |
| 113 | + <span className="text-ink-3">{x.label} </span> | |
| 114 | + <span className="text-ink-2">{x.value}</span> | |
| 115 | + </span> | |
| 116 | + ))} | |
| 117 | + </p> | |
| 118 | + )} | |
| 119 | + </div> | |
| 120 | + <div className="flex shrink-0 flex-col items-end gap-1 text-right"> | |
| 121 | + {trailing} | |
| 122 | + <QualityMark q={e.quality?.score} /> | |
| 123 | + </div> | |
| 124 | + </div> | |
| 125 | + </li> | |
| 126 | + ); | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** Quality score 0–100 (how well AI Atlas knows the entity, not how good it is). */ | |
| 130 | +export function QualityMark({ q, className, label = false }: { q: number | undefined | null; className?: string; label?: boolean }) { | |
| 131 | + const n = num(q); | |
| 132 | + if (n === null) return null; | |
| 133 | + const tone = n >= 75 ? 'bg-positive' : n >= 45 ? 'bg-accent' : 'bg-warning'; | |
| 134 | + return ( | |
| 135 | + <span className={cn('inline-flex items-center gap-1.5 text-xs text-ink-3', className)} title={`Data quality ${Math.round(n)}/100 — how well AI Atlas knows this entity`}> | |
| 136 | + {label && <span>quality</span>} | |
| 137 | + <span className="relative h-1 w-10 overflow-hidden rounded-sm bg-rule-strong"> | |
| 138 | + <span className={cn('absolute inset-y-0 left-0', tone)} style={{ width: `${Math.min(100, Math.max(0, n))}%` }} /> | |
| 139 | + </span> | |
| 140 | + <span className="tnum text-ink-2">{Math.round(n)}</span> | |
| 141 | + </span> | |
| 142 | + ); | |
| 143 | +} | |
| 144 | + | |
| 145 | +/** Inline compact list of entity links "a, b, c +4". */ | |
| 146 | +export function EntityInline({ items, max = 6, total }: { items: EntitySummary[]; max?: number; total?: number }) { | |
| 147 | + const shown = items.slice(0, max); | |
| 148 | + const rest = (total ?? items.length) - shown.length; | |
| 149 | + return ( | |
| 150 | + <span className="text-sm"> | |
| 151 | + {shown.map((e, i) => ( | |
| 152 | + <span key={e.id}> | |
| 153 | + {i > 0 && <span className="text-ink-3">, </span>} | |
| 154 | + <EntityLink e={e} /> | |
| 155 | + </span> | |
| 156 | + ))} | |
| 157 | + {rest > 0 && <span className="text-ink-3"> +{rest}</span>} | |
| 158 | + </span> | |
| 159 | + ); | |
| 160 | +} | |
| 161 | + | |
| 162 | +export function typeOf(e: { entity_type: string }): string { | |
| 163 | + return typeLabel(e.entity_type); | |
| 164 | +} | |
added
apps/web/src/components/ui/key-value.tsx
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | +import { cn } from '@/lib/cn'; | |
| 3 | +import { fmtValue } from '@/lib/format'; | |
| 4 | +import { propertyLabel, URL_KEYS } from '@/lib/site'; | |
| 5 | +import type { Provenance } from '@/lib/types'; | |
| 6 | +import { ProvenanceInline } from './provenance'; | |
| 7 | +import { Missing } from './unavailable'; | |
| 8 | + | |
| 9 | +export type KVRow = { key: string; label?: ReactNode; value?: ReactNode; raw?: unknown; hint?: ReactNode }; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Dense spec list (<dl>): label column + value + optional per-field provenance. Pass `provenance` (from EntityDetail) | |
| 13 | + * to render "Source · tier · observed" under each value when known. Values fall back to `fmtValue(raw, key)`. | |
| 14 | + */ | |
| 15 | +export function KeyValue({ rows, provenance, className, dense = false, showMissing = false }: { rows: KVRow[]; provenance?: Provenance; className?: string; dense?: boolean; showMissing?: boolean }) { | |
| 16 | + const shown = rows.filter((r) => showMissing || r.value !== undefined || (r.raw !== undefined && r.raw !== null && r.raw !== '')); | |
| 17 | + if (shown.length === 0) return <p className="text-sm text-ink-3">No structured attributes yet.</p>; | |
| 18 | + return ( | |
| 19 | + <dl className={cn('kv', dense && '[&>div]:py-1.5', className)}> | |
| 20 | + {shown.map((r) => { | |
| 21 | + const p = provenance?.[r.key]; | |
| 22 | + const raw = r.raw; | |
| 23 | + let value: ReactNode = r.value; | |
| 24 | + if (value === undefined) { | |
| 25 | + if (raw === null || raw === undefined || raw === '') value = <Missing />; | |
| 26 | + else if (URL_KEYS.has(r.key) && typeof raw === 'string' && /^https?:\/\//.test(raw)) | |
| 27 | + value = ( | |
| 28 | + <a href={raw} className="link break-all" rel="noopener noreferrer" target="_blank"> | |
| 29 | + {raw.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')} | |
| 30 | + </a> | |
| 31 | + ); | |
| 32 | + else value = <span className={typeof raw === 'number' ? 'tnum' : undefined}>{fmtValue(raw, r.key)}</span>; | |
| 33 | + } | |
| 34 | + return ( | |
| 35 | + <div key={r.key}> | |
| 36 | + <dt>{r.label ?? propertyLabel(r.key)}</dt> | |
| 37 | + <dd> | |
| 38 | + <div className="text-ink">{value}</div> | |
| 39 | + {r.hint && <div className="mt-0.5 text-xs text-ink-3">{r.hint}</div>} | |
| 40 | + {p && <ProvenanceInline p={p} className="mt-0.5" />} | |
| 41 | + </dd> | |
| 42 | + </div> | |
| 43 | + ); | |
| 44 | + })} | |
| 45 | + </dl> | |
| 46 | + ); | |
| 47 | +} | |
added
apps/web/src/components/ui/live.tsx
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useEffect, useState } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { fmtAgo } from '@/lib/format'; | |
| 5 | + | |
| 6 | +/** Green live dot with optional pulse ring. */ | |
| 7 | +export function Dot({ pulse = false, className, tone = 'positive' }: { pulse?: boolean; className?: string; tone?: 'positive' | 'warning' | 'danger' | 'muted' }) { | |
| 8 | + return <span className={cn('dot', pulse && 'pulse', tone === 'warning' && 'bg-warning', tone === 'danger' && 'bg-danger', tone === 'muted' && 'bg-ink-3', className)} aria-hidden />; | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** "3 min ago" that re-renders every 30 s. Renders the server value first (no hydration mismatch: same formatting). */ | |
| 12 | +export function LiveAgo({ at, prefix = '', className }: { at: string | null | undefined; prefix?: string; className?: string }) { | |
| 13 | + const [now, setNow] = useState<number | null>(null); | |
| 14 | + useEffect(() => { | |
| 15 | + setNow(Date.now()); | |
| 16 | + const t = setInterval(() => setNow(Date.now()), 30_000); | |
| 17 | + return () => clearInterval(t); | |
| 18 | + }, []); | |
| 19 | + return ( | |
| 20 | + <time dateTime={at ?? undefined} className={cn('tnum', className)} suppressHydrationWarning> | |
| 21 | + {prefix} | |
| 22 | + {fmtAgo(at, now ?? undefined)} | |
| 23 | + </time> | |
| 24 | + ); | |
| 25 | +} | |
added
apps/web/src/components/ui/pagination.tsx
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { cn } from '@/lib/cn'; | |
| 3 | +import { fmtInt } from '@/lib/format'; | |
| 4 | + | |
| 5 | +/** URL-driven pagination (server component). `makeHref(offset)` builds the link preserving current filters. */ | |
| 6 | +export function Pagination({ total, limit, offset, makeHref, className }: { total: number; limit: number; offset: number; makeHref: (offset: number) => string; className?: string }) { | |
| 7 | + const pages = Math.max(1, Math.ceil(total / limit)); | |
| 8 | + const page = Math.floor(offset / limit) + 1; | |
| 9 | + if (pages <= 1) return <p className={cn('tnum text-xs text-ink-3', className)}>{fmtInt(total)} results</p>; | |
| 10 | + const from = offset + 1; | |
| 11 | + const to = Math.min(total, offset + limit); | |
| 12 | + const btn = 'inline-flex h-10 min-w-10 items-center justify-center border border-rule px-3 text-sm text-ink-2 hover:bg-surface-2 hover:text-ink'; | |
| 13 | + return ( | |
| 14 | + <nav className={cn('flex flex-wrap items-center justify-between gap-3', className)} aria-label="Pagination"> | |
| 15 | + <p className="tnum text-xs text-ink-3"> | |
| 16 | + {fmtInt(from)}–{fmtInt(to)} of {fmtInt(total)} | |
| 17 | + </p> | |
| 18 | + <div className="flex items-center gap-1.5"> | |
| 19 | + {page > 1 ? ( | |
| 20 | + <Link href={makeHref(Math.max(0, offset - limit))} className={btn} rel="prev"> | |
| 21 | + ‹ Prev | |
| 22 | + </Link> | |
| 23 | + ) : ( | |
| 24 | + <span className={cn(btn, 'opacity-40')}>‹ Prev</span> | |
| 25 | + )} | |
| 26 | + <span className="mono px-2 text-xs text-ink-3"> | |
| 27 | + {page} / {fmtInt(pages)} | |
| 28 | + </span> | |
| 29 | + {page < pages ? ( | |
| 30 | + <Link href={makeHref(offset + limit)} className={btn} rel="next"> | |
| 31 | + Next › | |
| 32 | + </Link> | |
| 33 | + ) : ( | |
| 34 | + <span className={cn(btn, 'opacity-40')}>Next ›</span> | |
| 35 | + )} | |
| 36 | + </div> | |
| 37 | + </nav> | |
| 38 | + ); | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Build a query-string href preserving existing params. */ | |
| 42 | +export function withParams(base: string, current: Record<string, string | undefined>, patch: Record<string, string | number | undefined | null>): string { | |
| 43 | + const p = new URLSearchParams(); | |
| 44 | + for (const [k, v] of Object.entries(current)) if (v !== undefined && v !== '') p.set(k, v); | |
| 45 | + for (const [k, v] of Object.entries(patch)) { | |
| 46 | + if (v === undefined || v === null || v === '' || v === 0 || v === '0') p.delete(k); | |
| 47 | + else p.set(k, String(v)); | |
| 48 | + } | |
| 49 | + const s = p.toString(); | |
| 50 | + return s ? `${base}?${s}` : base; | |
| 51 | +} | |
added
apps/web/src/components/ui/provenance.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +import { cn } from '@/lib/cn'; | |
| 2 | +import { fmtAgo } from '@/lib/format'; | |
| 3 | +import type { ProvenanceEntry } from '@/lib/types'; | |
| 4 | +import { TierBadge } from './badges'; | |
| 5 | + | |
| 6 | +function domain(url: string | null | undefined): string | null { | |
| 7 | + if (!url) return null; | |
| 8 | + try { | |
| 9 | + return new URL(url).hostname.replace(/^www\./, ''); | |
| 10 | + } catch { | |
| 11 | + return null; | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** One-line provenance: "Source: docs.claude.com · T1 · observed 3 h ago · high". Server-safe. */ | |
| 16 | +export function ProvenanceInline({ p, className, showConfidence = true }: { p: ProvenanceEntry; className?: string; showConfidence?: boolean }) { | |
| 17 | + const name = p.source_name ?? domain(p.url) ?? 'source'; | |
| 18 | + return ( | |
| 19 | + <p className={cn('flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-4 text-ink-3', className)}> | |
| 20 | + <span>Source:</span> | |
| 21 | + {p.url ? ( | |
| 22 | + <a href={p.url} target="_blank" rel="noopener noreferrer" className="truncate text-ink-2 hover:text-accent hover:underline"> | |
| 23 | + {name} | |
| 24 | + </a> | |
| 25 | + ) : ( | |
| 26 | + <span className="text-ink-2">{name}</span> | |
| 27 | + )} | |
| 28 | + <span aria-hidden>·</span> | |
| 29 | + <TierBadge tier={p.tier} /> | |
| 30 | + <span aria-hidden>·</span> | |
| 31 | + <span title={p.observed_at}>observed {fmtAgo(p.observed_at)}</span> | |
| 32 | + {showConfidence && p.confidence && ( | |
| 33 | + <> | |
| 34 | + <span aria-hidden>·</span> | |
| 35 | + <span className={cn(p.confidence === 'conflicted' && 'text-danger', p.confidence === 'low' && 'text-warning')}>{p.confidence}</span> | |
| 36 | + </> | |
| 37 | + )} | |
| 38 | + {p.extractor && p.extractor.startsWith('llm') && ( | |
| 39 | + <> | |
| 40 | + <span aria-hidden>·</span> | |
| 41 | + <span title="Extracted by the local LLM factory">LLM-extracted</span> | |
| 42 | + </> | |
| 43 | + )} | |
| 44 | + </p> | |
| 45 | + ); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Compact source cell for tables: domain link + tier. */ | |
| 49 | +export function SourceCell({ url, tier, name, observedAt, className }: { url: string | null | undefined; tier?: number | null; name?: string | null; observedAt?: string | null; className?: string }) { | |
| 50 | + const label = name ?? domain(url); | |
| 51 | + return ( | |
| 52 | + <span className={cn('inline-flex flex-wrap items-center gap-1.5 text-xs text-ink-3', className)}> | |
| 53 | + {url && label ? ( | |
| 54 | + <a href={url} target="_blank" rel="noopener noreferrer" className="max-w-[16rem] truncate text-ink-2 hover:text-accent hover:underline"> | |
| 55 | + {label} | |
| 56 | + </a> | |
| 57 | + ) : label ? ( | |
| 58 | + <span>{label}</span> | |
| 59 | + ) : ( | |
| 60 | + <span>—</span> | |
| 61 | + )} | |
| 62 | + {tier !== undefined && <TierBadge tier={tier} />} | |
| 63 | + {observedAt && <span title={observedAt}>{fmtAgo(observedAt)}</span>} | |
| 64 | + </span> | |
| 65 | + ); | |
| 66 | +} | |
added
apps/web/src/components/ui/section.tsx
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import type { ReactNode } from 'react'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | + | |
| 5 | +/** Page-width wrapper. Pages render inside <main> without padding: wrap content in Container (1280) or Container wide (1600). */ | |
| 6 | +export function Container({ children, className, wide = false }: { children: ReactNode; className?: string; wide?: boolean }) { | |
| 7 | + return <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}>{children}</div>; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Section with eyebrow + title + optional action link. Spatial composition with a hairline — not a card. */ | |
| 11 | +export function Section({ | |
| 12 | + eyebrow, | |
| 13 | + title, | |
| 14 | + lede, | |
| 15 | + action, | |
| 16 | + children, | |
| 17 | + className, | |
| 18 | + id, | |
| 19 | + hairline = true, | |
| 20 | +}: { | |
| 21 | + eyebrow?: string; | |
| 22 | + title?: ReactNode; | |
| 23 | + lede?: ReactNode; | |
| 24 | + action?: { href: string; label: string }; | |
| 25 | + children: ReactNode; | |
| 26 | + className?: string; | |
| 27 | + id?: string; | |
| 28 | + hairline?: boolean; | |
| 29 | +}) { | |
| 30 | + return ( | |
| 31 | + <section id={id} className={cn('scroll-mt-20 py-7 md:py-10', hairline && 'hairline', className)}> | |
| 32 | + {(eyebrow || title) && ( | |
| 33 | + <div className="mb-4 flex items-end justify-between gap-4 md:mb-5"> | |
| 34 | + <div className="min-w-0"> | |
| 35 | + {eyebrow && <p className="eyebrow">{eyebrow}</p>} | |
| 36 | + {title && <h2 className="mt-1 text-lg font-semibold tracking-tight md:text-xl">{title}</h2>} | |
| 37 | + {lede && <p className="mt-1 max-w-2xl text-sm text-ink-2">{lede}</p>} | |
| 38 | + </div> | |
| 39 | + {action && ( | |
| 40 | + <Link href={action.href} className="link shrink-0 py-1 text-sm"> | |
| 41 | + {action.label} → | |
| 42 | + </Link> | |
| 43 | + )} | |
| 44 | + </div> | |
| 45 | + )} | |
| 46 | + {children} | |
| 47 | + </section> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function PageHeader({ eyebrow, title, lede, children, className, aside }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; children?: ReactNode; className?: string; aside?: ReactNode }) { | |
| 52 | + return ( | |
| 53 | + <div className={cn('pb-5 pt-7 md:pb-7 md:pt-10', className)}> | |
| 54 | + <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> | |
| 55 | + <div className="min-w-0"> | |
| 56 | + {eyebrow && <div className="eyebrow flex flex-wrap items-center gap-2">{eyebrow}</div>} | |
| 57 | + <h1 className="display mt-2 text-[28px] md:text-[40px]">{title}</h1> | |
| 58 | + {lede && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">{lede}</p>} | |
| 59 | + </div> | |
| 60 | + {aside && <div className="shrink-0">{aside}</div>} | |
| 61 | + </div> | |
| 62 | + {children} | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Stat tile: big tabular number + label + optional delta/hint. Composes in a hairline grid, never in a card. */ | |
| 68 | +export function Stat({ label, value, hint, delta, className, accent = false, href }: { label: string; value: ReactNode; hint?: ReactNode; delta?: { value: string; tone?: 'positive' | 'negative' | 'neutral' }; className?: string; accent?: boolean; href?: string }) { | |
| 69 | + const body = ( | |
| 70 | + <> | |
| 71 | + <p className="eyebrow">{label}</p> | |
| 72 | + <p className={cn('tnum mt-1 text-[26px] font-semibold leading-none tracking-tight md:text-[32px]', accent && 'text-accent')}>{value}</p> | |
| 73 | + {(hint || delta) && ( | |
| 74 | + <p className="mt-1.5 flex items-center gap-2 text-xs text-ink-3"> | |
| 75 | + {delta && <span className={cn('tnum font-medium', delta.tone === 'positive' && 'text-positive', delta.tone === 'negative' && 'text-danger', (!delta.tone || delta.tone === 'neutral') && 'text-accent-2')}>{delta.value}</span>} | |
| 76 | + {hint} | |
| 77 | + </p> | |
| 78 | + )} | |
| 79 | + </> | |
| 80 | + ); | |
| 81 | + if (href) | |
| 82 | + return ( | |
| 83 | + <Link href={href} className={cn('block min-w-0 py-3 hover:text-accent md:py-4', className)}> | |
| 84 | + {body} | |
| 85 | + </Link> | |
| 86 | + ); | |
| 87 | + return <div className={cn('min-w-0 py-3 md:py-4', className)}>{body}</div>; | |
| 88 | +} | |
| 89 | + | |
| 90 | +/** Grid of Stat tiles separated by hairlines (2 columns on mobile, n on desktop). */ | |
| 91 | +export function StatGrid({ children, cols = 4, className }: { children: ReactNode; cols?: 3 | 4 | 5 | 6 | 8; className?: string }) { | |
| 92 | + const desktop = { 3: 'md:grid-cols-3', 4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-6', 8: 'md:grid-cols-4 lg:grid-cols-8' }[cols]; | |
| 93 | + return <div className={cn('grid grid-cols-2 gap-x-6 border-y border-rule [&>*]:border-b [&>*]:border-rule md:[&>*]:border-b-0', desktop, className)}>{children}</div>; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Small honest note (derived / estimated / methodology pointer). */ | |
| 97 | +export function Note({ children, className }: { children: ReactNode; className?: string }) { | |
| 98 | + return <p className={cn('text-xs leading-relaxed text-ink-3', className)}>{children}</p>; | |
| 99 | +} | |
added
apps/web/src/components/ui/tabs.tsx
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; | |
| 3 | +import { type ReactNode, useEffect, useId, useRef, useState } from 'react'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | + | |
| 6 | +export type TabDef = { id: string; label: string; count?: number | null; hidden?: boolean }; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Accessible tabs driven by the URL: `?tab=<id>` (default) — or `#<id>` when `mode="hash"`. All panels are rendered | |
| 10 | + * server-side (SEO: one canonical URL carries the whole page); only the visible one is displayed. Arrow keys move focus. | |
| 11 | + * The tab strip scrolls horizontally on mobile with hidden scrollbar. | |
| 12 | + */ | |
| 13 | +export function Tabs({ tabs, defaultTab, children, mode = 'search', param = 'tab', className }: { tabs: TabDef[]; defaultTab?: string; children: ReactNode; mode?: 'search' | 'hash'; param?: string; className?: string }) { | |
| 14 | + const visible = tabs.filter((t) => !t.hidden); | |
| 15 | + const first = defaultTab ?? visible[0]?.id ?? ''; | |
| 16 | + const router = useRouter(); | |
| 17 | + const pathname = usePathname(); | |
| 18 | + const sp = useSearchParams(); | |
| 19 | + const [hash, setHash] = useState<string>(''); | |
| 20 | + const stripRef = useRef<HTMLDivElement>(null); | |
| 21 | + const uid = useId(); | |
| 22 | + | |
| 23 | + useEffect(() => { | |
| 24 | + if (mode !== 'hash') return; | |
| 25 | + const read = () => setHash(window.location.hash.replace(/^#/, '')); | |
| 26 | + read(); | |
| 27 | + window.addEventListener('hashchange', read); | |
| 28 | + return () => window.removeEventListener('hashchange', read); | |
| 29 | + }, [mode]); | |
| 30 | + | |
| 31 | + const requested = mode === 'hash' ? hash : sp.get(param) ?? ''; | |
| 32 | + const active = visible.some((t) => t.id === requested) ? requested : first; | |
| 33 | + | |
| 34 | + const select = (id: string) => { | |
| 35 | + if (mode === 'hash') { | |
| 36 | + window.history.replaceState(null, '', id === first ? pathname : `#${id}`); | |
| 37 | + setHash(id); | |
| 38 | + } else { | |
| 39 | + const next = new URLSearchParams(sp.toString()); | |
| 40 | + if (id === first) next.delete(param); | |
| 41 | + else next.set(param, id); | |
| 42 | + const q = next.toString(); | |
| 43 | + router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | |
| 44 | + } | |
| 45 | + }; | |
| 46 | + | |
| 47 | + useEffect(() => { | |
| 48 | + const el = stripRef.current?.querySelector<HTMLElement>(`[data-tab="${active}"]`); | |
| 49 | + el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); | |
| 50 | + }, [active]); | |
| 51 | + | |
| 52 | + const onKey = (e: React.KeyboardEvent) => { | |
| 53 | + const idx = visible.findIndex((t) => t.id === active); | |
| 54 | + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { | |
| 55 | + e.preventDefault(); | |
| 56 | + const n = visible[(idx + (e.key === 'ArrowRight' ? 1 : visible.length - 1)) % visible.length]; | |
| 57 | + if (n) { | |
| 58 | + select(n.id); | |
| 59 | + stripRef.current?.querySelector<HTMLElement>(`[data-tab="${n.id}"]`)?.focus(); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <div className={className} data-active-tab={active}> | |
| 66 | + <div ref={stripRef} role="tablist" aria-label="Sections" onKeyDown={onKey} className="no-scrollbar -mx-4 flex overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0"> | |
| 67 | + {visible.map((t) => { | |
| 68 | + const on = t.id === active; | |
| 69 | + return ( | |
| 70 | + <button | |
| 71 | + key={t.id} | |
| 72 | + type="button" | |
| 73 | + role="tab" | |
| 74 | + id={`${uid}-tab-${t.id}`} | |
| 75 | + data-tab={t.id} | |
| 76 | + aria-selected={on} | |
| 77 | + aria-controls={`${uid}-panel-${t.id}`} | |
| 78 | + tabIndex={on ? 0 : -1} | |
| 79 | + onClick={() => select(t.id)} | |
| 80 | + className={cn('-mb-px flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-3 text-sm whitespace-nowrap transition-colors first:pl-0', on ? 'border-ink text-ink font-medium' : 'border-transparent text-ink-2 hover:text-ink')} | |
| 81 | + > | |
| 82 | + {t.label} | |
| 83 | + {t.count !== undefined && t.count !== null && <span className={cn('tnum text-[11px]', on ? 'text-ink-2' : 'text-ink-3')}>{t.count}</span>} | |
| 84 | + </button> | |
| 85 | + ); | |
| 86 | + })} | |
| 87 | + </div> | |
| 88 | + <div data-tabs-panels="" className="[&>[data-tab-panel]]:hidden [&>[data-tab-panel][data-active=true]]:block"> | |
| 89 | + <TabsActive active={active} uid={uid}> | |
| 90 | + {children} | |
| 91 | + </TabsActive> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} | |
| 96 | + | |
| 97 | +import { createContext, useContext } from 'react'; | |
| 98 | +const Ctx = createContext<{ active: string; uid: string }>({ active: '', uid: '' }); | |
| 99 | +function TabsActive({ active, uid, children }: { active: string; uid: string; children: ReactNode }) { | |
| 100 | + return <Ctx.Provider value={{ active, uid }}>{children}</Ctx.Provider>; | |
| 101 | +} | |
| 102 | + | |
| 103 | +/** A panel inside <Tabs>. Always in the DOM; shown when its id is active. */ | |
| 104 | +export function TabPanel({ id, children, className }: { id: string; children: ReactNode; className?: string }) { | |
| 105 | + const { active, uid } = useContext(Ctx); | |
| 106 | + const on = active === id; | |
| 107 | + return ( | |
| 108 | + <div role="tabpanel" id={`${uid}-panel-${id}`} aria-labelledby={`${uid}-tab-${id}`} data-tab-panel="" data-active={on} hidden={!on} className={cn('pt-6', className)}> | |
| 109 | + {children} | |
| 110 | + </div> | |
| 111 | + ); | |
| 112 | +} | |
added
apps/web/src/components/ui/unavailable.tsx
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +import { cn } from '@/lib/cn'; | |
| 2 | + | |
| 3 | +/** Honest empty state: when a source/API is unavailable we say so instead of inventing numbers (CLAUDE.md non-negotiable 4). */ | |
| 4 | +export function Unavailable({ what = 'Data', className, compact = false, reason }: { what?: string; className?: string; compact?: boolean; reason?: string }) { | |
| 5 | + return ( | |
| 6 | + <div className={cn('border border-dashed border-rule-strong text-ink-3', compact ? 'inline-block px-2 py-0.5 text-xs' : 'px-5 py-8 text-center text-sm', className)} role="status"> | |
| 7 | + {what} unavailable | |
| 8 | + {reason && !compact && <span className="mt-1 block text-xs text-ink-3">{reason}</span>} | |
| 9 | + </div> | |
| 10 | + ); | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Inline dash for a missing value inside tables/specs. */ | |
| 14 | +export function Missing({ className }: { className?: string }) { | |
| 15 | + return ( | |
| 16 | + <span className={cn('text-ink-3', className)} aria-label="unavailable"> | |
| 17 | + — | |
| 18 | + </span> | |
| 19 | + ); | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function EmptyState({ title, children, className }: { title: string; children?: React.ReactNode; className?: string }) { | |
| 23 | + return ( | |
| 24 | + <div className={cn('border-y border-rule px-2 py-10 text-center', className)}> | |
| 25 | + <p className="text-sm font-medium text-ink">{title}</p> | |
| 26 | + {children && <div className="mt-2 text-sm text-ink-3">{children}</div>} | |
| 27 | + </div> | |
| 28 | + ); | |
| 29 | +} | |
added
apps/web/src/lib/api.ts
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import type { | |
| 3 | + AsOfPayload, | |
| 4 | + BenchmarkResult, | |
| 5 | + BenchmarkRow, | |
| 6 | + ChangeCategories, | |
| 7 | + ChangeEvent, | |
| 8 | + Claim, | |
| 9 | + CompaniesPage, | |
| 10 | + ComparePayload, | |
| 11 | + DailyDigest, | |
| 12 | + DiffPayload, | |
| 13 | + EntityDetail, | |
| 14 | + EntitySummary, | |
| 15 | + ExploreType, | |
| 16 | + GraphPayload, | |
| 17 | + HardwareFit, | |
| 18 | + Health, | |
| 19 | + Methodology, | |
| 20 | + ModelsPage, | |
| 21 | + Page, | |
| 22 | + Price, | |
| 23 | + PriceIndex, | |
| 24 | + ProviderRow, | |
| 25 | + SearchPayload, | |
| 26 | + SitemapPayload, | |
| 27 | + SourceRef, | |
| 28 | + SourceRow, | |
| 29 | + Stats, | |
| 30 | + StatsHistory, | |
| 31 | + Suggestion, | |
| 32 | + TimelinePayload, | |
| 33 | + TrendingRow, | |
| 34 | +} from './types'; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Typed fetch wrapper for the AI Atlas API (server components only — client code calls the same-origin `/api/v1/*` | |
| 38 | + * rewrite through `src/lib/client-api.ts`). | |
| 39 | + * | |
| 40 | + * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store). | |
| 41 | + * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages must render an | |
| 42 | + * "Unavailable" state rather than crash (graceful degradation). 404 → `notFound()` in the page. | |
| 43 | + * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel. | |
| 44 | + */ | |
| 45 | +export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, ''); | |
| 46 | +const BASE = `${API_URL}/api/v1`; | |
| 47 | + | |
| 48 | +export class ApiError extends Error { | |
| 49 | + readonly status: number; | |
| 50 | + readonly detail: string | null; | |
| 51 | + readonly path: string; | |
| 52 | + constructor(status: number, path: string, detail: string | null, message?: string) { | |
| 53 | + super(message ?? detail ?? `API ${status} on ${path}`); | |
| 54 | + this.name = 'ApiError'; | |
| 55 | + this.status = status; | |
| 56 | + this.detail = detail; | |
| 57 | + this.path = path; | |
| 58 | + } | |
| 59 | + get unavailable(): boolean { | |
| 60 | + return this.status === 503 || this.status === 0 || this.status >= 500; | |
| 61 | + } | |
| 62 | + get notFound(): boolean { | |
| 63 | + return this.status === 404; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +export interface FetchOptions { | |
| 68 | + /** Seconds; `false` → `cache: 'no-store'`. Default 300. */ | |
| 69 | + revalidate?: number | false; | |
| 70 | + tags?: string[]; | |
| 71 | +} | |
| 72 | +export type Query = Record<string, string | number | boolean | null | undefined>; | |
| 73 | + | |
| 74 | +function qs(query?: Query): string { | |
| 75 | + if (!query) return ''; | |
| 76 | + const p = new URLSearchParams(); | |
| 77 | + for (const [k, v] of Object.entries(query)) { | |
| 78 | + if (v === undefined || v === null || v === '') continue; | |
| 79 | + p.set(k, String(v)); | |
| 80 | + } | |
| 81 | + const s = p.toString(); | |
| 82 | + return s ? `?${s}` : ''; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> { | |
| 86 | + const url = `${BASE}${path}${qs(query)}`; | |
| 87 | + const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } }; | |
| 88 | + if (opts.revalidate === false) init.cache = 'no-store'; | |
| 89 | + else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags }; | |
| 90 | + let res: Response; | |
| 91 | + try { | |
| 92 | + res = await fetch(url, init); | |
| 93 | + } catch (e) { | |
| 94 | + throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`); | |
| 95 | + } | |
| 96 | + if (!res.ok) { | |
| 97 | + let detail: string | null = null; | |
| 98 | + try { | |
| 99 | + const body = (await res.json()) as { detail?: unknown }; | |
| 100 | + detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null; | |
| 101 | + } catch { | |
| 102 | + /* non-JSON error body */ | |
| 103 | + } | |
| 104 | + throw new ApiError(res.status, path, detail); | |
| 105 | + } | |
| 106 | + return (await res.json()) as T; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export async function safe<T>(p: Promise<T>): Promise<T | null> { | |
| 110 | + try { | |
| 111 | + return await p; | |
| 112 | + } catch { | |
| 113 | + return null; | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +const enc = encodeURIComponent; | |
| 118 | + | |
| 119 | +// ---------------------------------------------------------------------------------------------------------- endpoints | |
| 120 | +export const api = { | |
| 121 | + health: () => request<Health>('/health', undefined, { revalidate: false }), | |
| 122 | + stats: () => request<Stats>('/stats', undefined, { revalidate: 60 }), | |
| 123 | + statsHistory: (days = 90) => request<StatsHistory>('/stats/history', { days }, { revalidate: 900 }), | |
| 124 | + | |
| 125 | + search: (q: string, query: Query = {}) => request<SearchPayload>('/search', { q, ...query }, { revalidate: false }), | |
| 126 | + suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, { revalidate: false }), | |
| 127 | + | |
| 128 | + entity: (slugOrId: string) => request<EntityDetail>(`/entities/${enc(slugOrId)}`, undefined, { revalidate: 120 }), | |
| 129 | + /** Type-mounted detail: `/models/<slug>`, `/companies/<slug>`, … (404 if the type does not match). */ | |
| 130 | + entityOfType: (typePath: string, slug: string) => request<EntityDetail>(`/${typePath}/${enc(slug)}`, undefined, { revalidate: 120 }), | |
| 131 | + entityTimeline: (slug: string, limit = 50, before?: string) => request<{ items: ChangeEvent[] }>(`/entities/${enc(slug)}/timeline`, { limit, before }, { revalidate: 120 }), | |
| 132 | + entityHistory: (slug: string, property?: string) => request<{ items: Claim[] }>(`/entities/${enc(slug)}/history`, { property }, { revalidate: 300 }), | |
| 133 | + entityAsOf: (slug: string, date: string) => request<AsOfPayload>(`/entities/${enc(slug)}/asof`, { date }, { revalidate: 3600 }), | |
| 134 | + entityGraph: (slug: string, depth = 1, limit = 80) => request<GraphPayload>(`/entities/${enc(slug)}/graph`, { depth, limit }, { revalidate: 600 }), | |
| 135 | + entitySources: (slug: string) => request<{ items: SourceRef[] }>(`/entities/${enc(slug)}/sources`, undefined, { revalidate: 300 }), | |
| 136 | + entityRelated: (slug: string, limit = 12) => request<{ items: EntitySummary[] }>(`/entities/${enc(slug)}/related`, { limit }, { revalidate: 600 }), | |
| 137 | + | |
| 138 | + models: (query: Query) => request<ModelsPage>('/models', query, { revalidate: 120 }), | |
| 139 | + companies: (query: Query) => request<CompaniesPage>('/companies', query, { revalidate: 300 }), | |
| 140 | + papers: (query: Query) => request<Page<EntitySummary>>('/papers', query, { revalidate: 300 }), | |
| 141 | + providers: () => request<{ items: ProviderRow[] }>('/providers', undefined, { revalidate: 300 }), | |
| 142 | + prices: (query: Query) => request<Page<Price>>('/prices', { current: 1, ...query }, { revalidate: 300 }), | |
| 143 | + priceHistory: (query: Query) => request<{ items: Price[] }>('/prices/history', query, { revalidate: 600 }), | |
| 144 | + priceIndex: (days = 180) => request<PriceIndex>('/prices/index', { days }, { revalidate: 900 }), | |
| 145 | + benchmarks: () => request<{ items: BenchmarkRow[] }>('/benchmarks', undefined, { revalidate: 300 }), | |
| 146 | + benchmarkResults: (slug: string, query: Query = {}) => request<Page<BenchmarkResult>>(`/benchmarks/${enc(slug)}/results`, query, { revalidate: 300 }), | |
| 147 | + benchmarkHistory: (slug: string, model?: string) => request<{ items: BenchmarkResult[] }>(`/benchmarks/${enc(slug)}/history`, { model }, { revalidate: 600 }), | |
| 148 | + hardware: (query: Query) => request<Page<EntitySummary>>('/hardware', query, { revalidate: 600 }), | |
| 149 | + hardwareFit: (query: Query) => request<HardwareFit>('/hardware/fit', query, { revalidate: 600 }), | |
| 150 | + | |
| 151 | + exploreTypes: () => request<{ items: ExploreType[] }>('/explore/types', undefined, { revalidate: 300 }), | |
| 152 | + explore: (type: string, query: Query) => request<Page<EntitySummary>>(`/explore/${enc(type)}`, query, { revalidate: 300 }), | |
| 153 | + | |
| 154 | + changes: (query: Query) => request<Page<ChangeEvent>>('/changes', query, { revalidate: 60 }), | |
| 155 | + changesDaily: (date?: string) => request<DailyDigest>('/changes/daily', { date }, { revalidate: 300 }), | |
| 156 | + changesCategories: (days = 7) => request<ChangeCategories>('/changes/categories', { days }, { revalidate: 600 }), | |
| 157 | + timeline: (query: Query) => request<TimelinePayload>('/timeline', query, { revalidate: 600 }), | |
| 158 | + compare: (ids: string[]) => request<ComparePayload>('/compare', { ids: ids.join(',') }, { revalidate: 300 }), | |
| 159 | + diff: (a: string, b: string, scope = 'all') => request<DiffPayload>('/diff', { a, b, scope }, { revalidate: 3600 }), | |
| 160 | + | |
| 161 | + sources: () => request<{ items: SourceRow[] }>('/sources', undefined, { revalidate: 300 }), | |
| 162 | + methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }), | |
| 163 | + trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }), | |
| 164 | + sitemap: (type?: string, limit = 5000, offset = 0) => request<SitemapPayload>('/sitemap', { type, limit, offset }, { revalidate: 3600 }), | |
| 165 | +}; | |
| 166 | + | |
| 167 | +/** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */ | |
| 168 | +export async function countOfType(type: string): Promise<number | null> { | |
| 169 | + const s = await safe(api.stats()); | |
| 170 | + const v = s?.entities?.[type]; | |
| 171 | + if (v === undefined || v === null) return null; | |
| 172 | + const n = Number(v); | |
| 173 | + return Number.isFinite(n) ? n : null; | |
| 174 | +} | |
added
apps/web/src/lib/client-api.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +'use client'; | |
| 2 | +/** Browser-side fetches: same origin `/api/v1/*` (Next rewrite → FastAPI). Never import server `api.ts` in client components. */ | |
| 3 | +import type { ChangeEvent, Page, SearchPayload, Suggestion } from './types'; | |
| 4 | + | |
| 5 | +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { | |
| 6 | + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal }); | |
| 7 | + if (!res.ok) throw new Error(`API ${res.status} on ${path}`); | |
| 8 | + return (await res.json()) as T; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export const clientApi = { | |
| 12 | + suggest: (q: string, signal?: AbortSignal) => get<{ items: Suggestion[] }>(`/search/suggest?q=${encodeURIComponent(q)}`, signal), | |
| 13 | + search: (q: string, limit = 10, signal?: AbortSignal) => get<SearchPayload>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal), | |
| 14 | + changes: (qs: string, signal?: AbortSignal) => get<Page<ChangeEvent>>(`/changes?${qs}`, signal), | |
| 15 | + /** Page-view beacon (1 req/s/IP server-side). Fire-and-forget. */ | |
| 16 | + view: (path: string) => | |
| 17 | + fetch('/api/v1/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }), keepalive: true }).catch(() => undefined), | |
| 18 | +}; | |
added
apps/web/src/lib/cn.ts
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +/** Tiny class joiner (no dependency). */ | |
| 2 | +export function cn(...parts: Array<string | false | null | undefined>): string { | |
| 3 | + return parts.filter(Boolean).join(' '); | |
| 4 | +} | |
added
apps/web/src/lib/fonts.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Geist Sans (UI) + Geist Mono (ids, numbers, telemetry), self-hosted through the `geist` package — no network at build time. | |
| 3 | + * Exposed as CSS variables consumed by globals.css (`--font-ui`, `--font-mono`). | |
| 4 | + */ | |
| 5 | +import { GeistMono } from 'geist/font/mono'; | |
| 6 | +import { GeistSans } from 'geist/font/sans'; | |
| 7 | + | |
| 8 | +export const fontUi = GeistSans; | |
| 9 | +export const fontMono = GeistMono; | |
added
apps/web/src/lib/format.ts
+181 −0
@@ -0,0 +1,181 @@ | ||
| 1 | +/** | |
| 2 | + * Formatting helpers. API timestamps are UTC ISO strings; dates render in UTC. All helpers accept `Num` | |
| 3 | + * (Postgres aggregates can arrive as strings) and return the em-dash for missing values — never a fake number. | |
| 4 | + */ | |
| 5 | +import type { Num } from './types'; | |
| 6 | + | |
| 7 | +const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); | |
| 8 | +const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); | |
| 9 | +const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); | |
| 10 | +const nfCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); | |
| 11 | + | |
| 12 | +export const DASH = '—'; | |
| 13 | + | |
| 14 | +/** Coerce `Num`/unknown to a finite number or null. */ | |
| 15 | +export function num(v: unknown): number | null { | |
| 16 | + if (v === null || v === undefined || v === '') return null; | |
| 17 | + const n = typeof v === 'number' ? v : Number(v); | |
| 18 | + return Number.isFinite(n) ? n : null; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function fmtInt(v: Num | undefined | unknown): string { | |
| 22 | + const n = num(v); | |
| 23 | + return n === null ? DASH : nf0.format(n); | |
| 24 | +} | |
| 25 | +export function fmt1(v: Num | undefined | unknown): string { | |
| 26 | + const n = num(v); | |
| 27 | + return n === null ? DASH : nf1.format(n); | |
| 28 | +} | |
| 29 | +export function fmt2(v: Num | undefined | unknown): string { | |
| 30 | + const n = num(v); | |
| 31 | + return n === null ? DASH : nf2.format(n); | |
| 32 | +} | |
| 33 | +export function fmtCompact(v: Num | undefined | unknown): string { | |
| 34 | + const n = num(v); | |
| 35 | + return n === null ? DASH : nfCompact.format(n); | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Parameter counts: 7e9 → "7B", 1.8e12 → "1.8T", 350e6 → "350M". */ | |
| 39 | +export function fmtParams(v: Num | undefined | unknown): string { | |
| 40 | + const n = num(v); | |
| 41 | + if (n === null) return DASH; | |
| 42 | + if (n >= 1e12) return `${trim(n / 1e12)}T`; | |
| 43 | + if (n >= 1e9) return `${trim(n / 1e9)}B`; | |
| 44 | + if (n >= 1e6) return `${trim(n / 1e6)}M`; | |
| 45 | + if (n >= 1e3) return `${trim(n / 1e3)}K`; | |
| 46 | + return nf0.format(n); | |
| 47 | +} | |
| 48 | +/** Token windows: 128000 → "128K", 1000000 → "1M", 200000 → "200K". */ | |
| 49 | +export function fmtTokens(v: Num | undefined | unknown): string { | |
| 50 | + const n = num(v); | |
| 51 | + if (n === null) return DASH; | |
| 52 | + if (n >= 1e6) return `${trim(n / 1e6)}M`; | |
| 53 | + if (n >= 1e3) return `${trim(n / 1e3)}K`; | |
| 54 | + return nf0.format(n); | |
| 55 | +} | |
| 56 | +function trim(x: number): string { | |
| 57 | + const r = Math.round(x * 100) / 100; | |
| 58 | + return r % 1 === 0 ? String(r) : String(Number(r.toFixed(r < 10 ? 2 : 1))); | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** USD per 1M tokens: 3 → "$3.00", 0.075 → "$0.075", 15 → "$15.00". */ | |
| 62 | +export function fmtUsdPerM(v: Num | undefined | unknown, withUnit = false): string { | |
| 63 | + const n = num(v); | |
| 64 | + if (n === null) return DASH; | |
| 65 | + let s: string; | |
| 66 | + if (n === 0) s = '$0'; | |
| 67 | + else if (n < 0.01) s = `$${n.toFixed(4).replace(/0+$/, '')}`; | |
| 68 | + else if (n < 1) s = `$${n.toFixed(3).replace(/0$/, '')}`; | |
| 69 | + else s = `$${nf2.format(n)}`; | |
| 70 | + return withUnit ? `${s} / 1M` : s; | |
| 71 | +} | |
| 72 | +export function fmtUsd(v: Num | undefined | unknown): string { | |
| 73 | + const n = num(v); | |
| 74 | + return n === null ? DASH : `$${nf0.format(n)}`; | |
| 75 | +} | |
| 76 | +export function fmtPct(v: Num | undefined | unknown, digits = 1): string { | |
| 77 | + const n = num(v); | |
| 78 | + return n === null ? DASH : `${n.toFixed(digits)}%`; | |
| 79 | +} | |
| 80 | +export function fmtGb(v: Num | undefined | unknown, digits = 0): string { | |
| 81 | + const n = num(v); | |
| 82 | + return n === null ? DASH : `${digits ? n.toFixed(digits) : nf0.format(n)} GB`; | |
| 83 | +} | |
| 84 | +export function fmtBytes(v: Num | undefined | unknown): string { | |
| 85 | + const n = num(v); | |
| 86 | + if (n === null) return DASH; | |
| 87 | + if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TB`; | |
| 88 | + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`; | |
| 89 | + if (n >= 1e6) return `${(n / 1e6).toFixed(0)} MB`; | |
| 90 | + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`; | |
| 91 | + return `${n} B`; | |
| 92 | +} | |
| 93 | +export function fmtScore(v: Num | undefined | unknown): string { | |
| 94 | + const n = num(v); | |
| 95 | + if (n === null) return DASH; | |
| 96 | + return Number.isInteger(n) ? nf0.format(n) : nf2.format(n); | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** ISO date (YYYY-MM-DD, YYYY-MM or full timestamp) → "11 Sept 2026" / "Sept 2026" / "2026" (UTC). */ | |
| 100 | +export function fmtDate(v: string | null | undefined): string { | |
| 101 | + if (!v) return DASH; | |
| 102 | + if (/^\d{4}$/.test(v)) return v; | |
| 103 | + if (/^\d{4}-\d{2}$/.test(v)) return fmtMonth(v); | |
| 104 | + const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v); | |
| 105 | + if (Number.isNaN(d.getTime())) return v; | |
| 106 | + return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); | |
| 107 | +} | |
| 108 | +export function fmtMonth(v: string | null | undefined): string { | |
| 109 | + if (!v) return DASH; | |
| 110 | + const m = /^(\d{4})-(\d{2})/.exec(v); | |
| 111 | + if (!m) return fmtDate(v); | |
| 112 | + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, 1)); | |
| 113 | + return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', timeZone: 'UTC' }); | |
| 114 | +} | |
| 115 | +export function fmtDateTime(v: string | null | undefined): string { | |
| 116 | + if (!v) return DASH; | |
| 117 | + const d = new Date(v); | |
| 118 | + if (Number.isNaN(d.getTime())) return DASH; | |
| 119 | + return `${d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })} ${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`; | |
| 120 | +} | |
| 121 | +export function fmtAgo(v: string | null | undefined, now: number = Date.now()): string { | |
| 122 | + if (!v) return 'unavailable'; | |
| 123 | + const t = new Date(v).getTime(); | |
| 124 | + if (Number.isNaN(t)) return 'unavailable'; | |
| 125 | + const s = Math.max(0, Math.round((now - t) / 1000)); | |
| 126 | + if (s < 60) return 'just now'; | |
| 127 | + const m = Math.round(s / 60); | |
| 128 | + if (m < 60) return `${m} min ago`; | |
| 129 | + const h = Math.round(m / 60); | |
| 130 | + if (h < 48) return `${h} h ago`; | |
| 131 | + const d = Math.round(h / 24); | |
| 132 | + if (d < 60) return `${d} d ago`; | |
| 133 | + return fmtDate(v); | |
| 134 | +} | |
| 135 | +export function fmtYear(v: string | null | undefined): string { | |
| 136 | + return v && /^\d{4}/.test(v) ? v.slice(0, 4) : DASH; | |
| 137 | +} | |
| 138 | +export function fmtDuration(seconds: Num | undefined | unknown): string { | |
| 139 | + const n = num(seconds); | |
| 140 | + if (n === null) return DASH; | |
| 141 | + if (n < 60) return `${n}s`; | |
| 142 | + if (n < 3600) return `${Math.round(n / 60)} min`; | |
| 143 | + if (n < 86400) return `${Math.round(n / 3600)} h`; | |
| 144 | + return `${Math.round(n / 86400)} d`; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export function titleCase(s: string | null | undefined): string { | |
| 148 | + if (!s) return DASH; | |
| 149 | + return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 150 | +} | |
| 151 | +/** Event type / predicate constant → words: NEW_MODEL → "New model", fine_tuned_from → "fine-tuned from". */ | |
| 152 | +export function humanize(s: string | null | undefined): string { | |
| 153 | + if (!s) return DASH; | |
| 154 | + const w = s.toLowerCase().replace(/_/g, ' '); | |
| 155 | + return w.charAt(0).toUpperCase() + w.slice(1); | |
| 156 | +} | |
| 157 | +export function plural(n: number, one: string, many = `${one}s`): string { | |
| 158 | + return n === 1 ? one : many; | |
| 159 | +} | |
| 160 | + | |
| 161 | +/** Render any attribute value for a spec table (never JSON-dumps a primitive). */ | |
| 162 | +export function fmtValue(v: unknown, key?: string): string { | |
| 163 | + if (v === null || v === undefined || v === '') return DASH; | |
| 164 | + if (typeof v === 'boolean') return v ? 'Yes' : 'No'; | |
| 165 | + if (Array.isArray(v)) return v.length ? v.map((x) => (typeof x === 'object' && x ? JSON.stringify(x) : String(x))).join(', ') : DASH; | |
| 166 | + if (typeof v === 'number') { | |
| 167 | + if (key && /parameter_count/.test(key)) return fmtParams(v); | |
| 168 | + if (key && /(context_length|max_output_tokens)/.test(key)) return `${fmtTokens(v)} tokens`; | |
| 169 | + if (key && /memory_gb|file_size_gb/.test(key)) return fmtGb(v, v % 1 ? 1 : 0); | |
| 170 | + if (key && /bandwidth_gbs/.test(key)) return `${fmtInt(v)} GB/s`; | |
| 171 | + if (key && /tdp_watts/.test(key)) return `${fmtInt(v)} W`; | |
| 172 | + if (key && /tflops/.test(key)) return `${fmt1(v)} TFLOPS`; | |
| 173 | + if (key && /price_usd/.test(key)) return fmtUsd(v); | |
| 174 | + return Number.isInteger(v) ? nf0.format(v) : nf2.format(v); | |
| 175 | + } | |
| 176 | + if (typeof v === 'string') { | |
| 177 | + if (key && /(_date|_at|cutoff)$/.test(key) && /^\d{4}(-\d{2})?(-\d{2})?/.test(v)) return fmtDate(v); | |
| 178 | + return v; | |
| 179 | + } | |
| 180 | + return JSON.stringify(v); | |
| 181 | +} | |
added
apps/web/src/lib/site.ts
+438 −0
@@ -0,0 +1,438 @@ | ||
| 1 | +/** Site constants, routes per entity type, labels and colours shared by pages and components. */ | |
| 2 | + | |
| 3 | +export const SITE_NAME = 'AI Atlas'; | |
| 4 | +export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.ai-atlas.co').replace(/\/$/, ''); | |
| 5 | +export const TAGLINE = 'Explore the entire AI ecosystem'; | |
| 6 | +export const DESCRIPTION = | |
| 7 | + 'AI Atlas is a continuously updated, source-attributed map of the global AI ecosystem: models, companies, research, providers and pricing, benchmarks, hardware, frameworks, datasets and tools — with full provenance and history.'; | |
| 8 | +export const CONTACT_EMAIL = 'contact@spboucher.ai'; | |
| 9 | +export const PUBLIC_API_BASE = 'https://www.ai-atlas.co/api/v1'; | |
| 10 | +export const BOT_UA = 'AIAtlasBot'; | |
| 11 | + | |
| 12 | +/** Entity type → URL path segment (plural). Types not listed fall back to /explore/<type>/<slug> → generic entity page. */ | |
| 13 | +export const TYPE_PATH: Record<string, string> = { | |
| 14 | + model: 'models', | |
| 15 | + company: 'companies', | |
| 16 | + organization: 'companies', | |
| 17 | + lab: 'companies', | |
| 18 | + university: 'companies', | |
| 19 | + paper: 'papers', | |
| 20 | + provider: 'providers', | |
| 21 | + benchmark: 'benchmarks', | |
| 22 | + hardware: 'hardware', | |
| 23 | + framework: 'frameworks', | |
| 24 | + library: 'frameworks', | |
| 25 | + runtime: 'frameworks', | |
| 26 | + dataset: 'datasets', | |
| 27 | + tool: 'tools', | |
| 28 | + agent: 'tools', | |
| 29 | + mcp_server: 'tools', | |
| 30 | + product: 'tools', | |
| 31 | + repository: 'repositories', | |
| 32 | +}; | |
| 33 | +/** URL path segment → API mount + canonical entity types accepted there. */ | |
| 34 | +export const PATH_TYPES: Record<string, { label: string; singular: string; types: string[]; api: string }> = { | |
| 35 | + models: { label: 'Models', singular: 'Model', types: ['model', 'quantization'], api: 'models' }, | |
| 36 | + companies: { label: 'Companies', singular: 'Company', types: ['company', 'organization', 'lab', 'university'], api: 'companies' }, | |
| 37 | + papers: { label: 'Research', singular: 'Paper', types: ['paper'], api: 'papers' }, | |
| 38 | + providers: { label: 'Providers', singular: 'Provider', types: ['provider'], api: 'providers' }, | |
| 39 | + benchmarks: { label: 'Benchmarks', singular: 'Benchmark', types: ['benchmark'], api: 'benchmarks' }, | |
| 40 | + hardware: { label: 'Hardware', singular: 'Hardware', types: ['hardware'], api: 'hardware' }, | |
| 41 | + frameworks: { label: 'Frameworks', singular: 'Framework', types: ['framework', 'library', 'runtime'], api: 'frameworks' }, | |
| 42 | + datasets: { label: 'Datasets', singular: 'Dataset', types: ['dataset'], api: 'datasets' }, | |
| 43 | + tools: { label: 'Tools', singular: 'Tool', types: ['tool', 'agent', 'mcp_server', 'product'], api: 'tools' }, | |
| 44 | + repositories: { label: 'Repositories', singular: 'Repository', types: ['repository'], api: 'entities' }, | |
| 45 | +}; | |
| 46 | + | |
| 47 | +export const TYPE_LABELS: Record<string, string> = { | |
| 48 | + model: 'Model', | |
| 49 | + company: 'Company', | |
| 50 | + organization: 'Organization', | |
| 51 | + lab: 'Lab', | |
| 52 | + university: 'University', | |
| 53 | + researcher: 'Researcher', | |
| 54 | + paper: 'Paper', | |
| 55 | + dataset: 'Dataset', | |
| 56 | + benchmark: 'Benchmark', | |
| 57 | + provider: 'Provider', | |
| 58 | + framework: 'Framework', | |
| 59 | + library: 'Library', | |
| 60 | + runtime: 'Runtime', | |
| 61 | + repository: 'Repository', | |
| 62 | + tool: 'Tool', | |
| 63 | + agent: 'Agent', | |
| 64 | + mcp_server: 'MCP server', | |
| 65 | + product: 'Product', | |
| 66 | + hardware: 'Hardware', | |
| 67 | + quantization: 'Quantization', | |
| 68 | + license: 'License', | |
| 69 | + regulation: 'Regulation', | |
| 70 | + incident: 'Incident', | |
| 71 | + release: 'Release', | |
| 72 | + conference: 'Conference', | |
| 73 | + country: 'Country', | |
| 74 | + robot: 'Robot', | |
| 75 | + data_center: 'Data center', | |
| 76 | + standard: 'Standard', | |
| 77 | + funding_round: 'Funding round', | |
| 78 | + acquisition: 'Acquisition', | |
| 79 | + course: 'Course', | |
| 80 | + job: 'Job', | |
| 81 | +}; | |
| 82 | +export const TYPE_LABELS_PLURAL: Record<string, string> = { | |
| 83 | + model: 'Models', | |
| 84 | + company: 'Companies', | |
| 85 | + organization: 'Organizations', | |
| 86 | + lab: 'Labs', | |
| 87 | + university: 'Universities', | |
| 88 | + researcher: 'Researchers', | |
| 89 | + paper: 'Papers', | |
| 90 | + dataset: 'Datasets', | |
| 91 | + benchmark: 'Benchmarks', | |
| 92 | + provider: 'Providers', | |
| 93 | + framework: 'Frameworks', | |
| 94 | + library: 'Libraries', | |
| 95 | + runtime: 'Runtimes', | |
| 96 | + repository: 'Repositories', | |
| 97 | + tool: 'Tools', | |
| 98 | + agent: 'Agents', | |
| 99 | + mcp_server: 'MCP servers', | |
| 100 | + product: 'Products', | |
| 101 | + hardware: 'Hardware', | |
| 102 | + quantization: 'Quantizations', | |
| 103 | + license: 'Licenses', | |
| 104 | + regulation: 'Regulations', | |
| 105 | + incident: 'Incidents', | |
| 106 | + release: 'Releases', | |
| 107 | + conference: 'Conferences', | |
| 108 | + country: 'Countries', | |
| 109 | + robot: 'Robots', | |
| 110 | + data_center: 'Data centers', | |
| 111 | + standard: 'Standards', | |
| 112 | + funding_round: 'Funding rounds', | |
| 113 | + acquisition: 'Acquisitions', | |
| 114 | + course: 'Courses', | |
| 115 | + job: 'Jobs', | |
| 116 | +}; | |
| 117 | +export function typeLabel(t: string, pluralForm = false): string { | |
| 118 | + return (pluralForm ? TYPE_LABELS_PLURAL[t] : TYPE_LABELS[t]) ?? t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Badge colour family per entity type (CSS variables `--type-<key>` in globals.css). */ | |
| 122 | +export const TYPE_COLOR_KEY: Record<string, string> = { | |
| 123 | + model: 'model', | |
| 124 | + quantization: 'model', | |
| 125 | + company: 'company', | |
| 126 | + organization: 'company', | |
| 127 | + lab: 'company', | |
| 128 | + university: 'company', | |
| 129 | + researcher: 'company', | |
| 130 | + paper: 'paper', | |
| 131 | + provider: 'provider', | |
| 132 | + benchmark: 'benchmark', | |
| 133 | + hardware: 'hardware', | |
| 134 | + robot: 'hardware', | |
| 135 | + data_center: 'hardware', | |
| 136 | + framework: 'framework', | |
| 137 | + library: 'framework', | |
| 138 | + runtime: 'framework', | |
| 139 | + repository: 'framework', | |
| 140 | + dataset: 'dataset', | |
| 141 | + tool: 'tool', | |
| 142 | + agent: 'tool', | |
| 143 | + mcp_server: 'tool', | |
| 144 | + product: 'tool', | |
| 145 | +}; | |
| 146 | + | |
| 147 | +export const routes = { | |
| 148 | + home: () => '/', | |
| 149 | + search: (q: string, type?: string) => `/search?q=${encodeURIComponent(q)}${type ? `&type=${encodeURIComponent(type)}` : ''}`, | |
| 150 | + models: () => '/models', | |
| 151 | + companies: () => '/companies', | |
| 152 | + papers: () => '/papers', | |
| 153 | + providers: () => '/providers', | |
| 154 | + benchmarks: () => '/benchmarks', | |
| 155 | + hardware: () => '/hardware', | |
| 156 | + frameworks: () => '/frameworks', | |
| 157 | + datasets: () => '/datasets', | |
| 158 | + tools: () => '/tools', | |
| 159 | + changes: () => '/changes', | |
| 160 | + changesDay: (date: string) => `/changes/${date}`, | |
| 161 | + timeline: () => '/timeline', | |
| 162 | + compare: (ids?: string[]) => (ids?.length ? `/compare?ids=${ids.map(encodeURIComponent).join(',')}` : '/compare'), | |
| 163 | + explore: () => '/explore', | |
| 164 | + exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`, | |
| 165 | + methodology: () => '/methodology', | |
| 166 | + sources: () => '/sources', | |
| 167 | + about: () => '/about', | |
| 168 | + developers: () => '/developers', | |
| 169 | + bot: () => '/bot', | |
| 170 | + listing: (type: string) => { | |
| 171 | + const p = TYPE_PATH[type]; | |
| 172 | + return p ? `/${p}` : `/explore/${encodeURIComponent(type)}`; | |
| 173 | + }, | |
| 174 | + /** Canonical URL of any entity: /models/<slug>, /companies/<slug>, … or /explore/<type>/<slug>. */ | |
| 175 | + entity: (e: { entity_type: string; slug: string }) => { | |
| 176 | + const p = TYPE_PATH[e.entity_type]; | |
| 177 | + return p ? `/${p}/${encodeURIComponent(e.slug)}` : `/explore/${encodeURIComponent(e.entity_type)}/${encodeURIComponent(e.slug)}`; | |
| 178 | + }, | |
| 179 | +}; | |
| 180 | + | |
| 181 | +export const exploreNav = [ | |
| 182 | + { href: '/models', label: 'Models', type: 'model' }, | |
| 183 | + { href: '/companies', label: 'Companies', type: 'company' }, | |
| 184 | + { href: '/papers', label: 'Research', type: 'paper' }, | |
| 185 | + { href: '/providers', label: 'Providers & Pricing', type: 'provider' }, | |
| 186 | + { href: '/benchmarks', label: 'Benchmarks', type: 'benchmark' }, | |
| 187 | + { href: '/hardware', label: 'Hardware', type: 'hardware' }, | |
| 188 | + { href: '/frameworks', label: 'Frameworks', type: 'framework' }, | |
| 189 | + { href: '/datasets', label: 'Datasets', type: 'dataset' }, | |
| 190 | + { href: '/tools', label: 'Tools', type: 'tool' }, | |
| 191 | +]; | |
| 192 | +export const primaryNav = [ | |
| 193 | + { href: '/changes', label: 'Changes' }, | |
| 194 | + { href: '/timeline', label: 'Timeline' }, | |
| 195 | + { href: '/compare', label: 'Compare' }, | |
| 196 | + { href: '/developers', label: 'Developers' }, | |
| 197 | +]; | |
| 198 | +export const moreNav = [ | |
| 199 | + { href: '/about', label: 'About' }, | |
| 200 | + { href: '/methodology', label: 'Methodology' }, | |
| 201 | + { href: '/sources', label: 'Sources' }, | |
| 202 | + { href: '/developers', label: 'API & Developers' }, | |
| 203 | + { href: '/bot', label: 'AIAtlasBot' }, | |
| 204 | +]; | |
| 205 | + | |
| 206 | +/** Change-event vocabulary (sdk/facts.py MATERIAL_PROPERTIES + writer): label, colour family. */ | |
| 207 | +export const EVENT_TYPE_LABELS: Record<string, string> = { | |
| 208 | + NEW_MODEL: 'New model', | |
| 209 | + NEW_COMPANY: 'New company', | |
| 210 | + NEW_PROVIDER: 'New provider', | |
| 211 | + NEW_PAPER: 'New paper', | |
| 212 | + NEW_DATASET: 'New dataset', | |
| 213 | + NEW_BENCHMARK: 'New benchmark', | |
| 214 | + NEW_FRAMEWORK: 'New framework', | |
| 215 | + NEW_HARDWARE: 'New hardware', | |
| 216 | + NEW_TOOL: 'New tool', | |
| 217 | + NEW_REPOSITORY: 'New repository', | |
| 218 | + NEW_ORGANIZATION: 'New organization', | |
| 219 | + NEW_ENTITY: 'New entity', | |
| 220 | + MODEL_UPDATED: 'Model updated', | |
| 221 | + PRICE_CHANGED: 'Price changed', | |
| 222 | + PROVIDER_LISTED: 'Listed by provider', | |
| 223 | + CONTEXT_CHANGED: 'Context window changed', | |
| 224 | + MAX_OUTPUT_CHANGED: 'Max output changed', | |
| 225 | + STATUS_CHANGED: 'Status changed', | |
| 226 | + LICENSE_CHANGED: 'License changed', | |
| 227 | + OPENNESS_CHANGED: 'Openness changed', | |
| 228 | + PARAMETERS_CHANGED: 'Parameters changed', | |
| 229 | + RELEASE_DATE_CHANGED: 'Release date changed', | |
| 230 | + KNOWLEDGE_CUTOFF_CHANGED: 'Knowledge cutoff changed', | |
| 231 | + CAPABILITIES_CHANGED: 'Capabilities changed', | |
| 232 | + RATE_LIMIT_CHANGED: 'Rate limit changed', | |
| 233 | + SPEC_CHANGED: 'Spec changed', | |
| 234 | + DEPRECATION_ANNOUNCED: 'Deprecation announced', | |
| 235 | + RETIREMENT_ANNOUNCED: 'Retirement announced', | |
| 236 | + BENCHMARK_RESULT: 'Benchmark result', | |
| 237 | + BENCHMARK_UPDATED: 'Benchmark updated', | |
| 238 | + ANNOUNCEMENT: 'Announcement', | |
| 239 | + RELEASE: 'Release', | |
| 240 | + VERSION_RELEASED: 'Version released', | |
| 241 | + PROPERTY_CHANGED: 'Property changed', | |
| 242 | + DOCUMENT_CHANGED: 'Document changed', | |
| 243 | +}; | |
| 244 | +export function eventLabel(t: string): string { | |
| 245 | + return EVENT_TYPE_LABELS[t] ?? t.toLowerCase().replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); | |
| 246 | +} | |
| 247 | +/** Colour family for an event type: accent-2 (amber) for money, positive for new, warning/danger for deprecations. */ | |
| 248 | +export function eventTone(t: string): 'new' | 'price' | 'warn' | 'danger' | 'bench' | 'neutral' { | |
| 249 | + if (t.startsWith('NEW_') || t === 'RELEASE' || t === 'VERSION_RELEASED') return 'new'; | |
| 250 | + if (t === 'PRICE_CHANGED' || t === 'PROVIDER_LISTED') return 'price'; | |
| 251 | + if (t === 'DEPRECATION_ANNOUNCED') return 'warn'; | |
| 252 | + if (t === 'RETIREMENT_ANNOUNCED') return 'danger'; | |
| 253 | + if (t.startsWith('BENCHMARK')) return 'bench'; | |
| 254 | + return 'neutral'; | |
| 255 | +} | |
| 256 | + | |
| 257 | +export const CATEGORY_LABELS: Record<string, string> = { | |
| 258 | + model: 'Models', | |
| 259 | + company: 'Companies', | |
| 260 | + paper: 'Research', | |
| 261 | + dataset: 'Datasets', | |
| 262 | + benchmark: 'Benchmarks', | |
| 263 | + provider: 'Providers & pricing', | |
| 264 | + price: 'Pricing', | |
| 265 | + framework: 'Frameworks', | |
| 266 | + repository: 'Repositories', | |
| 267 | + tool: 'Tools', | |
| 268 | + hardware: 'Hardware', | |
| 269 | + regulation: 'Regulation', | |
| 270 | + incident: 'Incidents', | |
| 271 | + release: 'Releases', | |
| 272 | + document: 'Documents', | |
| 273 | + announcement: 'Announcements', | |
| 274 | +}; | |
| 275 | +export function categoryLabel(c: string): string { | |
| 276 | + return CATEGORY_LABELS[c] ?? c.replace(/_/g, ' ').replace(/^\w/, (x) => x.toUpperCase()); | |
| 277 | +} | |
| 278 | + | |
| 279 | +export const IMPORTANCE_LABELS: Record<number, string> = { 0: 'Minor', 1: 'Notable', 2: 'Important', 3: 'Major' }; | |
| 280 | + | |
| 281 | +/** Human labels for attribute keys (docs/CONNECTORS.md property vocabulary). */ | |
| 282 | +export const PROPERTY_LABELS: Record<string, string> = { | |
| 283 | + family: 'Family', | |
| 284 | + version: 'Version', | |
| 285 | + release_date: 'Release date', | |
| 286 | + status: 'Status', | |
| 287 | + openness: 'Openness', | |
| 288 | + license: 'License', | |
| 289 | + architecture: 'Architecture', | |
| 290 | + parameter_count: 'Parameters', | |
| 291 | + active_parameter_count: 'Active parameters', | |
| 292 | + is_moe: 'Mixture of experts', | |
| 293 | + modalities: 'Modalities', | |
| 294 | + modalities_input: 'Input modalities', | |
| 295 | + modalities_output: 'Output modalities', | |
| 296 | + context_length: 'Context window', | |
| 297 | + max_output_tokens: 'Max output', | |
| 298 | + knowledge_cutoff: 'Knowledge cutoff', | |
| 299 | + training_data_cutoff: 'Training data cutoff', | |
| 300 | + languages: 'Languages', | |
| 301 | + tool_calling: 'Tool calling', | |
| 302 | + structured_output: 'Structured output', | |
| 303 | + reasoning: 'Reasoning', | |
| 304 | + vision: 'Vision', | |
| 305 | + audio: 'Audio', | |
| 306 | + fine_tuning_available: 'Fine-tuning available', | |
| 307 | + tokenizer: 'Tokenizer', | |
| 308 | + api_model_id: 'API model id', | |
| 309 | + api_alias: 'API alias', | |
| 310 | + official_url: 'Official page', | |
| 311 | + model_card_url: 'Model card', | |
| 312 | + paper_url: 'Paper', | |
| 313 | + repository_url: 'Repository', | |
| 314 | + hf_repo: 'Hugging Face repo', | |
| 315 | + pipeline_tag: 'Pipeline tag', | |
| 316 | + base_model: 'Base model', | |
| 317 | + quantization: 'Quantization', | |
| 318 | + quant_format: 'Quantization format', | |
| 319 | + file_size_gb: 'File size', | |
| 320 | + deprecation_date: 'Deprecation date', | |
| 321 | + retirement_date: 'Retirement date', | |
| 322 | + retirement_tentative: 'Retirement tentative', | |
| 323 | + 'metric.downloads': 'Downloads', | |
| 324 | + 'metric.likes': 'Likes', | |
| 325 | + 'metric.stars': 'Stars', | |
| 326 | + 'metric.forks': 'Forks', | |
| 327 | + country: 'Country', | |
| 328 | + headquarters: 'Headquarters', | |
| 329 | + founded: 'Founded', | |
| 330 | + website: 'Website', | |
| 331 | + domains: 'Domains', | |
| 332 | + hf_org: 'Hugging Face org', | |
| 333 | + github_org: 'GitHub org', | |
| 334 | + org_kind: 'Kind', | |
| 335 | + legal_name: 'Legal name', | |
| 336 | + founders: 'Founders', | |
| 337 | + leadership: 'Leadership', | |
| 338 | + employee_count: 'Employees', | |
| 339 | + pricing_url: 'Pricing page', | |
| 340 | + docs_url: 'Documentation', | |
| 341 | + regions: 'Regions', | |
| 342 | + features: 'Features', | |
| 343 | + authors: 'Authors', | |
| 344 | + published_at: 'Published', | |
| 345 | + updated_at: 'Updated', | |
| 346 | + abstract: 'Abstract', | |
| 347 | + arxiv_id: 'arXiv id', | |
| 348 | + doi: 'DOI', | |
| 349 | + categories: 'Categories', | |
| 350 | + primary_category: 'Primary category', | |
| 351 | + pdf_url: 'PDF', | |
| 352 | + code_url: 'Code', | |
| 353 | + venue: 'Venue', | |
| 354 | + category: 'Category', | |
| 355 | + task: 'Task', | |
| 356 | + metric: 'Metric', | |
| 357 | + unit: 'Unit', | |
| 358 | + creator: 'Creator', | |
| 359 | + paper: 'Paper', | |
| 360 | + known_limitations: 'Known limitations', | |
| 361 | + methodology: 'Methodology', | |
| 362 | + kind: 'Kind', | |
| 363 | + memory_gb: 'Memory', | |
| 364 | + memory_type: 'Memory type', | |
| 365 | + memory_bandwidth_gbs: 'Memory bandwidth', | |
| 366 | + tdp_watts: 'TDP', | |
| 367 | + runtimes: 'Runtimes', | |
| 368 | + manufacturer: 'Manufacturer', | |
| 369 | + spec_url: 'Spec sheet', | |
| 370 | + price_usd: 'Price (USD)', | |
| 371 | + compute_fp16_tflops: 'FP16 compute', | |
| 372 | + latest_version: 'Latest version', | |
| 373 | + latest_release_at: 'Latest release', | |
| 374 | + language: 'Language', | |
| 375 | + topics: 'Topics', | |
| 376 | + pypi: 'PyPI', | |
| 377 | + modality: 'Modality', | |
| 378 | + size: 'Size', | |
| 379 | + publisher: 'Publisher', | |
| 380 | + description: 'Description', | |
| 381 | +}; | |
| 382 | +export function propertyLabel(k: string): string { | |
| 383 | + return PROPERTY_LABELS[k] ?? k.replace(/^metric\./, '').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); | |
| 384 | +} | |
| 385 | +/** Attribute keys that are URLs (rendered as links). */ | |
| 386 | +export const URL_KEYS = new Set(['official_url', 'model_card_url', 'paper_url', 'repository_url', 'website', 'pricing_url', 'docs_url', 'pdf_url', 'code_url', 'spec_url']); | |
| 387 | +/** Soft / long text properties rendered as prose, not in the spec table. */ | |
| 388 | +export const PROSE_KEYS = new Set(['description', 'abstract', 'summary', 'tagline', 'availability_note', 'notes', 'training_data_notes', 'safety_notes', 'hardware_requirements', 'known_limitations', 'methodology']); | |
| 389 | + | |
| 390 | +export const PREDICATE_LABELS: Record<string, { out: string; in: string }> = { | |
| 391 | + develops: { out: 'Develops', in: 'Developed by' }, | |
| 392 | + owns: { out: 'Owns', in: 'Owned by' }, | |
| 393 | + operates: { out: 'Operates', in: 'Operated by' }, | |
| 394 | + available_through: { out: 'Available through', in: 'Serves' }, | |
| 395 | + evaluated_on: { out: 'Evaluated on', in: 'Evaluated models' }, | |
| 396 | + described_by: { out: 'Described by', in: 'Describes' }, | |
| 397 | + derived_from: { out: 'Derived from', in: 'Derivatives' }, | |
| 398 | + fine_tuned_from: { out: 'Fine-tuned from', in: 'Fine-tunes' }, | |
| 399 | + quantized_from: { out: 'Quantized from', in: 'Quantizations' }, | |
| 400 | + distilled_from: { out: 'Distilled from', in: 'Distillations' }, | |
| 401 | + merged_from: { out: 'Merged from', in: 'Merged into' }, | |
| 402 | + superseded_by: { out: 'Superseded by', in: 'Supersedes' }, | |
| 403 | + runs_on: { out: 'Runs on', in: 'Runs' }, | |
| 404 | + uses: { out: 'Uses', in: 'Used by' }, | |
| 405 | + manufactures: { out: 'Manufactures', in: 'Manufactured by' }, | |
| 406 | + funded_by: { out: 'Funded by', in: 'Funds' }, | |
| 407 | + acquired: { out: 'Acquired', in: 'Acquired by' }, | |
| 408 | + authored: { out: 'Authored', in: 'Authors' }, | |
| 409 | + works_at: { out: 'Works at', in: 'People' }, | |
| 410 | + uses_dataset: { out: 'Trained on', in: 'Used to train' }, | |
| 411 | + evaluates_on: { out: 'Evaluates on', in: 'Evaluated by' }, | |
| 412 | + integrates: { out: 'Integrates', in: 'Integrated by' }, | |
| 413 | + published_by: { out: 'Published by', in: 'Publications' }, | |
| 414 | +}; | |
| 415 | +export function predicateLabel(p: string, direction: 'out' | 'in'): string { | |
| 416 | + return PREDICATE_LABELS[p]?.[direction] ?? p.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); | |
| 417 | +} | |
| 418 | + | |
| 419 | +export const OPENNESS_LABELS: Record<string, string> = { 'open-weights': 'Open weights', 'open-source': 'Open source', proprietary: 'Proprietary', restricted: 'Restricted' }; | |
| 420 | +export const STATUS_LABELS: Record<string, string> = { | |
| 421 | + active: 'Active', | |
| 422 | + preview: 'Preview', | |
| 423 | + deprecated: 'Deprecated', | |
| 424 | + retired: 'Retired', | |
| 425 | + announced: 'Announced', | |
| 426 | + 'limited-availability': 'Limited availability', | |
| 427 | + unknown: 'Unknown', | |
| 428 | +}; | |
| 429 | +export const TIER_LABELS: Record<number, string> = { 1: 'Official', 2: 'Quality secondary', 3: 'Community', 4: 'Unverified' }; | |
| 430 | + | |
| 431 | +export const EXAMPLE_QUERIES = [ | |
| 432 | + 'open models with more than 100B parameters', | |
| 433 | + 'cheapest model with 1M context', | |
| 434 | + 'models released in 2026', | |
| 435 | + 'Anthropic', | |
| 436 | + 'GPQA leaderboard', | |
| 437 | + 'MoE models with 128k context', | |
| 438 | +]; | |
added
apps/web/src/lib/types.ts
+380 −0
@@ -0,0 +1,380 @@ | ||
| 1 | +/** | |
| 2 | + * Types mirroring docs/API.md (contract v1). Postgres aggregates may arrive as strings → `Num`; always go through `num()`/`fmt*`. | |
| 3 | + * Unknown/extra keys are tolerated: the web layer only reads what it renders. | |
| 4 | + */ | |
| 5 | + | |
| 6 | +export type Num = number | string | null; | |
| 7 | +export type Org = { id: string; slug: string; name: string } | null; | |
| 8 | + | |
| 9 | +export type Quality = { score?: number; completeness?: number; primary_source_ratio?: number; freshness?: number; source_count?: number; conflicts?: number }; | |
| 10 | +export type Counts = { relations?: number; events?: number; claims?: number }; | |
| 11 | + | |
| 12 | +export interface EntitySummary { | |
| 13 | + id: string; | |
| 14 | + entity_type: string; | |
| 15 | + slug: string; | |
| 16 | + name: string; | |
| 17 | + description: string | null; | |
| 18 | + status: string; | |
| 19 | + organization: Org; | |
| 20 | + attributes: Record<string, unknown>; | |
| 21 | + quality: Quality; | |
| 22 | + counts: Counts; | |
| 23 | + first_seen_at: string; | |
| 24 | + last_seen_at: string; | |
| 25 | + updated_at: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface Page<T> { | |
| 29 | + items: T[]; | |
| 30 | + total: number; | |
| 31 | + limit: number; | |
| 32 | + offset: number; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export type ProvenanceEntry = { | |
| 36 | + source_id: string | null; | |
| 37 | + source_name?: string; | |
| 38 | + url: string | null; | |
| 39 | + observed_at: string; | |
| 40 | + tier: number; | |
| 41 | + confidence: string; | |
| 42 | + extractor: string; | |
| 43 | + unit?: string; | |
| 44 | +}; | |
| 45 | +export type Provenance = Record<string, ProvenanceEntry>; | |
| 46 | + | |
| 47 | +export type Importance = 0 | 1 | 2 | 3; | |
| 48 | + | |
| 49 | +export interface ChangeEvent { | |
| 50 | + id: string; | |
| 51 | + event_type: string; | |
| 52 | + category: string; | |
| 53 | + property: string | null; | |
| 54 | + old_value: unknown; | |
| 55 | + new_value: unknown; | |
| 56 | + summary: string; | |
| 57 | + importance: Importance; | |
| 58 | + observed_at: string; | |
| 59 | + effective_at: string | null; | |
| 60 | + source_url: string | null; | |
| 61 | + connector_name: string | null; | |
| 62 | + entity: EntitySummary | null; | |
| 63 | + meta: Record<string, unknown>; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export interface Price { | |
| 67 | + id: string; | |
| 68 | + model: EntitySummary; | |
| 69 | + provider: EntitySummary; | |
| 70 | + provider_model_id: string | null; | |
| 71 | + input_per_mtok: Num; | |
| 72 | + output_per_mtok: Num; | |
| 73 | + cached_input_per_mtok: Num; | |
| 74 | + cache_write_per_mtok: Num; | |
| 75 | + batch_input_per_mtok: Num; | |
| 76 | + batch_output_per_mtok: Num; | |
| 77 | + per_image: Num; | |
| 78 | + currency: string; | |
| 79 | + context_length: Num; | |
| 80 | + max_output_tokens: Num; | |
| 81 | + features: Record<string, unknown>; | |
| 82 | + observed_at: string; | |
| 83 | + valid_from: string; | |
| 84 | + valid_to: string | null; | |
| 85 | + source_url: string | null; | |
| 86 | + tier: number; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export interface BenchmarkResult { | |
| 90 | + id: string; | |
| 91 | + model: EntitySummary; | |
| 92 | + benchmark: EntitySummary; | |
| 93 | + score: number; | |
| 94 | + metric: string | null; | |
| 95 | + unit: string | null; | |
| 96 | + higher_is_better: boolean; | |
| 97 | + config: Record<string, unknown>; | |
| 98 | + evaluated_at: string | null; | |
| 99 | + observed_at: string; | |
| 100 | + source_url: string | null; | |
| 101 | + tier: number; | |
| 102 | + confidence: string; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export interface Claim { | |
| 106 | + id: string; | |
| 107 | + property: string; | |
| 108 | + value: unknown; | |
| 109 | + unit: string | null; | |
| 110 | + tier: number; | |
| 111 | + confidence: string; | |
| 112 | + status: string; | |
| 113 | + extractor: string; | |
| 114 | + observed_at: string; | |
| 115 | + effective_at: string | null; | |
| 116 | + valid_from: string; | |
| 117 | + valid_to: string | null; | |
| 118 | + source_url: string | null; | |
| 119 | + source_name: string | null; | |
| 120 | +} | |
| 121 | + | |
| 122 | +export interface SourceRef { | |
| 123 | + source_id: string | null; | |
| 124 | + source_name: string | null; | |
| 125 | + domain: string | null; | |
| 126 | + tier: number | null; | |
| 127 | + url: string; | |
| 128 | + doc_type: string; | |
| 129 | + last_observed_at: string | null; | |
| 130 | + snapshots: number; | |
| 131 | +} | |
| 132 | + | |
| 133 | +export interface RelationGroup { | |
| 134 | + predicate: string; | |
| 135 | + direction: 'out' | 'in'; | |
| 136 | + items: EntitySummary[]; | |
| 137 | + total: number; | |
| 138 | +} | |
| 139 | + | |
| 140 | +export interface Lineage { | |
| 141 | + ancestors: EntitySummary[]; | |
| 142 | + descendants: EntitySummary[]; | |
| 143 | + quantizations: EntitySummary[]; | |
| 144 | +} | |
| 145 | + | |
| 146 | +export interface HardwareFitRow { | |
| 147 | + hardware: EntitySummary; | |
| 148 | + quantization: string; | |
| 149 | + estimated_memory_gb: number; | |
| 150 | + fits: boolean; | |
| 151 | +} | |
| 152 | + | |
| 153 | +export interface EntityDetail extends EntitySummary { | |
| 154 | + provenance: Provenance; | |
| 155 | + aliases: string[]; | |
| 156 | + identifiers: { scheme: string; value: string }[]; | |
| 157 | + relations: RelationGroup[]; | |
| 158 | + sources: SourceRef[]; | |
| 159 | + timeline: ChangeEvent[]; | |
| 160 | + prices?: Price[]; | |
| 161 | + price_history?: Price[]; | |
| 162 | + results?: BenchmarkResult[]; | |
| 163 | + lineage?: Lineage; | |
| 164 | + providers?: EntitySummary[]; | |
| 165 | + hardware_fit?: HardwareFitRow[]; | |
| 166 | + models?: Page<EntitySummary>; | |
| 167 | + papers?: EntitySummary[]; | |
| 168 | + repositories?: EntitySummary[]; | |
| 169 | + // companies listing enrichments may also be present on detail | |
| 170 | + model_count?: number; | |
| 171 | + paper_count?: number; | |
| 172 | +} | |
| 173 | + | |
| 174 | +export interface Health { | |
| 175 | + status: 'ok' | 'degraded' | string; | |
| 176 | + version: string; | |
| 177 | + db: boolean; | |
| 178 | + redis: boolean; | |
| 179 | + llm: { available: boolean; reachable?: boolean }; | |
| 180 | + time: string; | |
| 181 | +} | |
| 182 | + | |
| 183 | +export interface Stats { | |
| 184 | + entities: Record<string, Num>; | |
| 185 | + entities_total: Num; | |
| 186 | + sources: Num; | |
| 187 | + connectors: Num; | |
| 188 | + connectors_enabled: Num; | |
| 189 | + documents: Num; | |
| 190 | + snapshots: Num; | |
| 191 | + claims: Num; | |
| 192 | + claims_current: Num; | |
| 193 | + relations: Num; | |
| 194 | + change_events: Num; | |
| 195 | + change_events_24h: Num; | |
| 196 | + change_events_7d: Num; | |
| 197 | + benchmark_results: Num; | |
| 198 | + prices_current: Num; | |
| 199 | + prices_total: Num; | |
| 200 | + review_pending: Num; | |
| 201 | + llm_jobs: Num; | |
| 202 | + llm_tokens: Num; | |
| 203 | + last_snapshot_at: string | null; | |
| 204 | + last_event_at: string | null; | |
| 205 | + first_entity_at: string | null; | |
| 206 | + archive: { raw_bytes: Num; raw_files: Num; text_bytes: Num; text_files: Num }; | |
| 207 | + computed_at: string; | |
| 208 | +} | |
| 209 | + | |
| 210 | +export interface StatsHistory { | |
| 211 | + items: { day: string; counts: Record<string, Num> }[]; | |
| 212 | +} | |
| 213 | + | |
| 214 | +/** Output of services.search.compile_query — shape is loose; we render what we recognise. */ | |
| 215 | +export interface CompiledQuery { | |
| 216 | + text?: string | null; | |
| 217 | + q?: string | null; | |
| 218 | + entity_type?: string | null; | |
| 219 | + type?: string | null; | |
| 220 | + filters?: Record<string, unknown>; | |
| 221 | + [k: string]: unknown; | |
| 222 | +} | |
| 223 | + | |
| 224 | +export interface SearchPayload { | |
| 225 | + query: CompiledQuery; | |
| 226 | + items: (EntitySummary & { rank: number })[]; | |
| 227 | + total: number; | |
| 228 | +} | |
| 229 | + | |
| 230 | +export interface Suggestion { | |
| 231 | + id: string; | |
| 232 | + entity_type: string; | |
| 233 | + slug: string; | |
| 234 | + name: string; | |
| 235 | + organization_name: string | null; | |
| 236 | +} | |
| 237 | + | |
| 238 | +export interface FacetValue { | |
| 239 | + value: string; | |
| 240 | + count: Num; | |
| 241 | +} | |
| 242 | +export interface OrgFacet { | |
| 243 | + slug: string; | |
| 244 | + name: string; | |
| 245 | + count: Num; | |
| 246 | +} | |
| 247 | +export interface ModelFacets { | |
| 248 | + organizations?: OrgFacet[]; | |
| 249 | + openness?: FacetValue[]; | |
| 250 | + modalities?: FacetValue[]; | |
| 251 | + families?: FacetValue[]; | |
| 252 | + years?: FacetValue[]; | |
| 253 | + licenses?: FacetValue[]; | |
| 254 | + status?: FacetValue[]; | |
| 255 | +} | |
| 256 | +export type ModelsPage = Page<EntitySummary> & { facets?: ModelFacets }; | |
| 257 | + | |
| 258 | +export type CompanyRow = EntitySummary & { model_count: Num; paper_count: Num }; | |
| 259 | +export interface CompanyFacets { | |
| 260 | + countries?: FacetValue[]; | |
| 261 | + kinds?: FacetValue[]; | |
| 262 | +} | |
| 263 | +export type CompaniesPage = Page<CompanyRow> & { facets?: CompanyFacets }; | |
| 264 | + | |
| 265 | +export type ProviderRow = EntitySummary & { model_count: Num; price_count: Num; min_input_per_mtok: Num; min_output_per_mtok: Num }; | |
| 266 | +export type BenchmarkRow = EntitySummary & { result_count: Num; model_count: Num; top: { model: EntitySummary; score: number } | null }; | |
| 267 | + | |
| 268 | +export interface PriceIndex { | |
| 269 | + series: { day: string; median_input: Num; median_output: Num; min_input: Num; models: number }[]; | |
| 270 | + movers: ChangeEvent[]; | |
| 271 | +} | |
| 272 | + | |
| 273 | +export interface HardwareFit { | |
| 274 | + inputs: Record<string, unknown>; | |
| 275 | + assumptions: string[]; | |
| 276 | + items: { model: EntitySummary; parameter_count: number; estimated_memory_gb: number; fits: boolean; headroom_gb: number; quantization: string; note: string }[]; | |
| 277 | +} | |
| 278 | + | |
| 279 | +export interface ExploreType { | |
| 280 | + entity_type: string; | |
| 281 | + count: Num; | |
| 282 | + label: string; | |
| 283 | +} | |
| 284 | + | |
| 285 | +export interface DailyDigest { | |
| 286 | + date: string; | |
| 287 | + counts: Record<string, Num>; | |
| 288 | + sections: { category: string; label: string; items: ChangeEvent[] }[]; | |
| 289 | + new_models: EntitySummary[]; | |
| 290 | +} | |
| 291 | + | |
| 292 | +export interface ChangeCategories { | |
| 293 | + items: { category: string; event_type: string; count: Num }[]; | |
| 294 | +} | |
| 295 | + | |
| 296 | +export interface TimelinePayload { | |
| 297 | + items: { month: string; events: ChangeEvent[] }[]; | |
| 298 | +} | |
| 299 | + | |
| 300 | +export interface CompareDimension { | |
| 301 | + key: string; | |
| 302 | + label: string; | |
| 303 | + unit?: string; | |
| 304 | + kind: 'number' | 'text' | 'list' | 'bool' | 'date'; | |
| 305 | +} | |
| 306 | +export interface ComparePayload { | |
| 307 | + entity_type: string; | |
| 308 | + dimensions: CompareDimension[]; | |
| 309 | + items: { entity: EntitySummary; values: Record<string, unknown>; provenance: Provenance; prices?: Price[]; results?: BenchmarkResult[] }[]; | |
| 310 | +} | |
| 311 | + | |
| 312 | +export interface DiffPayload { | |
| 313 | + a: string; | |
| 314 | + b: string; | |
| 315 | + new_entities: EntitySummary[]; | |
| 316 | + gone_entities: EntitySummary[]; | |
| 317 | + property_changes: ChangeEvent[]; | |
| 318 | + price_changes: ChangeEvent[]; | |
| 319 | + benchmark_changes: ChangeEvent[]; | |
| 320 | + counts: Record<string, Num>; | |
| 321 | +} | |
| 322 | + | |
| 323 | +export interface ConnectorHealth { | |
| 324 | + name: string; | |
| 325 | + label: string; | |
| 326 | + health: string; | |
| 327 | + last_success_at: string | null; | |
| 328 | + interval_seconds: Num; | |
| 329 | +} | |
| 330 | +export interface SourceRow { | |
| 331 | + key: string; | |
| 332 | + name: string; | |
| 333 | + domain: string; | |
| 334 | + tier: number; | |
| 335 | + kind: string; | |
| 336 | + category: string; | |
| 337 | + organization: Org; | |
| 338 | + enabled: boolean; | |
| 339 | + documents: Num; | |
| 340 | + last_crawled_at: string | null; | |
| 341 | + connectors: ConnectorHealth[]; | |
| 342 | +} | |
| 343 | + | |
| 344 | +export interface MetricDefinition { | |
| 345 | + key?: string; | |
| 346 | + name?: string; | |
| 347 | + label?: string; | |
| 348 | + description?: string; | |
| 349 | + formula?: string; | |
| 350 | + version?: string | number; | |
| 351 | + unit?: string; | |
| 352 | + [k: string]: unknown; | |
| 353 | +} | |
| 354 | +export interface Methodology { | |
| 355 | + metrics: MetricDefinition[]; | |
| 356 | + confidence_levels: Record<string, string> | { key: string; label?: string; description?: string }[] | string[]; | |
| 357 | + tiers: Record<string, string> | { tier: number; label?: string; description?: string }[]; | |
| 358 | + event_types: Record<string, string> | { event_type: string; category?: string; label?: string; description?: string; importance?: number }[] | string[]; | |
| 359 | + extractors: Record<string, string> | { key?: string; name?: string; description?: string }[] | string[]; | |
| 360 | +} | |
| 361 | + | |
| 362 | +export type TrendingRow = EntitySummary & { views: Num }; | |
| 363 | + | |
| 364 | +export interface SitemapPayload { | |
| 365 | + items: { slug: string; entity_type: string; updated_at: string }[]; | |
| 366 | + total: number; | |
| 367 | +} | |
| 368 | + | |
| 369 | +export interface GraphPayload { | |
| 370 | + nodes: { id: string; slug: string; name: string; entity_type: string; organization_name?: string }[]; | |
| 371 | + edges: { source: string; target: string; predicate: string }[]; | |
| 372 | +} | |
| 373 | + | |
| 374 | +export interface AsOfPayload { | |
| 375 | + existed: boolean; | |
| 376 | + first_seen_at: string | null; | |
| 377 | + date: string; | |
| 378 | + attributes: Record<string, unknown>; | |
| 379 | + claims: Claim[]; | |
| 380 | +} | |
added
apps/web/tsconfig.json
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "lib": ["dom", "dom.iterable", "esnext"], | |
| 5 | + "allowJs": true, | |
| 6 | + "skipLibCheck": true, | |
| 7 | + "strict": true, | |
| 8 | + "noUncheckedIndexedAccess": true, | |
| 9 | + "noEmit": true, | |
| 10 | + "esModuleInterop": true, | |
| 11 | + "module": "esnext", | |
| 12 | + "moduleResolution": "bundler", | |
| 13 | + "resolveJsonModule": true, | |
| 14 | + "isolatedModules": true, | |
| 15 | + "jsx": "react-jsx", | |
| 16 | + "incremental": true, | |
| 17 | + "plugins": [{ "name": "next" }], | |
| 18 | + "paths": { "@/*": ["./src/*"] } | |
| 19 | + }, | |
| 20 | + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], | |
| 21 | + "exclude": ["node_modules", "qa"] | |
| 22 | +} | |
added
docs/FRONTEND.md
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +# AI Atlas web — frontend guide (`apps/web`) | |
| 2 | + | |
| 3 | +Next 16.3 (App Router, React 19, TypeScript strict, Tailwind v4, Geist). Server components by default; client components | |
| 4 | +only for interactivity (`'use client'`). The FastAPI service runs on `http://127.0.0.1:8331` in dev (`:8321` in prod); the | |
| 5 | +browser talks to the same origin — `/api/v1/*` and `/health` are rewritten to the API in `next.config.ts`. Root `.env` is | |
| 6 | +loaded by `next.config.ts` (`process.loadEnvFile`), so `API_URL` / `NEXT_PUBLIC_SITE_URL` live at the repo root. | |
| 7 | + | |
| 8 | +```bash | |
| 9 | +pnpm install # repo root | |
| 10 | +cd apps/web && pnpm dev # http://localhost:8330 (prod: pnpm start → :8320) | |
| 11 | +pnpm typecheck && pnpm build # must both pass before you report | |
| 12 | +node qa/screens.mjs # Playwright sweep → qa/screens/*.png (see "Verification") | |
| 13 | +``` | |
| 14 | + | |
| 15 | +## 1. Principles (non-negotiable, from CLAUDE.md) | |
| 16 | + | |
| 17 | +- **Every number comes from the API.** No hardcoded counts, dates or sample rows. Missing → `<Unavailable/>` / `—` / "Unavailable". | |
| 18 | +- **Provenance is a feature.** Show source, tier, observed time (and confidence) wherever a value is displayed in detail; link to `/methodology`. | |
| 19 | +- **Estimates are labelled** (`<Estimated/>`), and never mixed with observed facts. Only hardware fit is derived. | |
| 20 | +- **Mobile first** (390 / 430 / 768 / 1440): no horizontal overflow, ≥ 44 px targets, DOM order = visual order (no `order:` tricks), | |
| 21 | + tables stack (`.data-table.stack`) or scroll (`scroll` prop) — never overflow the page. | |
| 22 | +- **Dark and light** must both look finished; use tokens only (never raw hex in components). | |
| 23 | +- **Not a SaaS template**: hairlines and spatial composition, not stacks of rounded cards. `.panel` only for dialogs/sheets/callouts. | |
| 24 | +- Every public page: `generateMetadata` (title, description, `alternates.canonical`, OG), loading + error + empty states, source attribution. | |
| 25 | + | |
| 26 | +## 2. Design tokens (`src/app/globals.css`) | |
| 27 | + | |
| 28 | +Themes live on `<html data-theme="light|dark">` (set before paint by `THEME_SCRIPT`, persisted in `localStorage['aia-theme']`, | |
| 29 | +system by default; `ThemeToggle` cycles system → light → dark). Tokens are CSS variables exposed to Tailwind through `@theme inline`: | |
| 30 | + | |
| 31 | +| Family | Utilities | Use | | |
| 32 | +|---|---|---| | |
| 33 | +| Surfaces | `bg-canvas` (page) · `bg-surface` (inputs, dialogs) · `bg-surface-2` (hover wash, chips) · `bg-surface-3` (active) | Prefer canvas + hairlines; surfaces for interactive elements | | |
| 34 | +| Ink | `text-ink` · `text-ink-2` (secondary) · `text-ink-3` (labels, meta) | | | |
| 35 | +| Hairlines | `border-rule` · `border-rule-strong` · `.hairline` (top border) | Section separators, table rows | | |
| 36 | +| Accent | `accent` (atlas blue: links, active, focus) · `accent-ink` (text on accent) · `accent-soft` | Sparingly | | |
| 37 | +| Accent 2 | `accent-2` (amber) + `accent-2-soft` | **Money and change only** (prices, deltas) | | |
| 38 | +| Semantic | `positive` · `warning` · `danger` (+ `-soft`) | Status, health, confidence | | |
| 39 | +| Tiers | `tier-1` (green, official) · `tier-2` (blue) · `tier-3` (amber) · `tier-4` (grey) | Source quality (`TierBadge`) | | |
| 40 | +| Entity types | `type-model` `type-company` `type-paper` `type-provider` `type-benchmark` `type-hardware` `type-framework` `type-dataset` `type-tool` | `EntityBadge` (also `var(--type-<key>)`) | | |
| 41 | +| Charts | `series-1..8` (`var(--series-n)`) | SVG charts | | |
| 42 | + | |
| 43 | +Typography: **Geist Sans** for UI (`--font-sans`), **Geist Mono** for ids, numbers, telemetry (`.mono`). Base 15 px. | |
| 44 | +Utilities: `.eyebrow` (small caps label) · `.display` (headline) · `.mono` · `.tnum` (tabular numbers — use on every numeric cell) | |
| 45 | +· `.hairline` · `.link` · `.panel` (sparingly) · `.grid-bg` (hero) · `.scrollbar-thin` · `.no-scrollbar` · `.container-x` | |
| 46 | +· `.dot` / `.pulse` (live) · `.prose-atlas` · `.kv` (dense key–value grid, used by `KeyValue`) | |
| 47 | +· `.data-table` (+ `.stack` stacks rows < 768 px using `data-label`; `.compact`; `.num` right-aligned; `.primary` name cell; | |
| 48 | + `.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper). | |
| 49 | +Radii are deliberately small (4 px; 8 px for panels). Brand: `public/logo.svg`, `src/app/icon.svg`, `apple-icon.tsx`, | |
| 50 | +`opengraph-image.tsx` (live counters when the API answers), `components/brand/logo.tsx` (`LogoMark`, `Wordmark`). | |
| 51 | + | |
| 52 | +## 3. Data layer (`src/lib`) | |
| 53 | + | |
| 54 | +- `types.ts` — mirrors `docs/API.md` (`EntitySummary`, `EntityDetail`, `Page<T>`, `ChangeEvent`, `Price`, `BenchmarkResult`, | |
| 55 | + `Claim`, `SourceRef`, `Provenance`, `Stats`, `SearchPayload`, `DailyDigest`, `ComparePayload`, `DiffPayload`, `Methodology`, …). | |
| 56 | + Aggregates may arrive as strings → type `Num = number | string | null`; always go through `num()` / `fmt*`. | |
| 57 | +- `api.ts` (**server only**) — `api.<route>()` for every public route, `ApiError` (`.notFound`, `.unavailable`), `safe(p)` → `null` | |
| 58 | + on any failure, `request(path, query, { revalidate })` (ISR default 300 s; `false` = no-store). `countOfType(type)`. | |
| 59 | +- `client-api.ts` (**browser**) — `clientApi.suggest / search / changes / view` against same-origin `/api/v1`. | |
| 60 | +- `format.ts` — `num fmtInt fmt1 fmt2 fmtCompact fmtParams (70B) fmtTokens (128K) fmtUsdPerM ($3.00) fmtUsd fmtPct fmtGb fmtBytes fmtScore | |
| 61 | + fmtDate fmtMonth fmtDateTime fmtAgo fmtYear fmtDuration titleCase humanize plural fmtValue(value, key)`; `DASH = '—'`. | |
| 62 | +- `site.ts` — `SITE_NAME SITE_URL TAGLINE DESCRIPTION CONTACT_EMAIL PUBLIC_API_BASE BOT_UA`, `routes.*` (`routes.entity(e)` gives the | |
| 63 | + canonical URL for any entity type), `TYPE_PATH` / `PATH_TYPES` (type ↔ URL segment ↔ API mount), `typeLabel(t, plural)`, | |
| 64 | + `TYPE_COLOR_KEY`, nav arrays (`exploreNav primaryNav moreNav`), `EVENT_TYPE_LABELS eventLabel eventTone`, `CATEGORY_LABELS categoryLabel`, | |
| 65 | + `IMPORTANCE_LABELS`, `PROPERTY_LABELS propertyLabel URL_KEYS PROSE_KEYS`, `PREDICATE_LABELS predicateLabel`, `OPENNESS_LABELS STATUS_LABELS TIER_LABELS`, `EXAMPLE_QUERIES`. | |
| 66 | + | |
| 67 | +Page pattern: | |
| 68 | + | |
| 69 | +```tsx | |
| 70 | +const [a, b] = await Promise.all([safe(api.x()), safe(api.y())]); // never let one panel crash the page | |
| 71 | +if (!a) return <Unavailable what="…"/>; | |
| 72 | +``` | |
| 73 | + | |
| 74 | +Detail pages: `loadEntity(typePath, slug)` (`components/entity/load.ts`) → `notFound()` on API 404 **or** type mismatch; | |
| 75 | +`entityMetadata()` / `buildMetadata()` for `generateMetadata`; `permanentRedirect(routes.entity(d))` when the slug is not canonical. | |
| 76 | +Do **not** add `loading.tsx` to a segment that contains a dynamic `[slug]` route — it turns 404s into 200s (the listing is | |
| 77 | +in a route group `models/(list)/` for that reason). | |
| 78 | + | |
| 79 | +## 4. Components | |
| 80 | + | |
| 81 | +`components/ui/section.tsx` — `Container` (1280; `wide` = 1600) · `PageHeader` (eyebrow/title/lede/aside/children) · `Section` | |
| 82 | +(eyebrow/title/lede/action, `hairline`) · `Stat` (label/value/hint/delta/href) · `StatGrid` (cols 3–8) · `Note`. | |
| 83 | +`components/ui/badges.tsx` — `EntityBadge(type)` · `TierBadge(tier, withLabel)` · `ConfidenceBadge` · `StatusBadge` · `OpennessBadge` | |
| 84 | +· `ImportanceMark(0–3)` · `Chip(tone)` · `Estimated`. | |
| 85 | +`components/ui/unavailable.tsx` — `Unavailable(what, compact, reason)` · `Missing` · `EmptyState(title)`. | |
| 86 | +`components/ui/pagination.tsx` — `Pagination(total, limit, offset, makeHref)` · `withParams(base, current, patch)`. | |
| 87 | +`components/ui/tabs.tsx` (client) — `Tabs(tabs, mode='search'|'hash')` + `TabPanel(id)`; all panels are server-rendered, URL `?tab=` drives visibility. | |
| 88 | +`components/ui/key-value.tsx` — `KeyValue(rows, provenance)`: dense spec `<dl>`; each row `{ key, raw | value, label?, hint? }`, URLs auto-linked, provenance line under each value. | |
| 89 | +`components/ui/provenance.tsx` — `ProvenanceInline(p)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted") · `SourceCell(url, tier, observedAt)`. | |
| 90 | +`components/ui/data-table.tsx` — `DataTable(stack|scroll|compact)` · `Th(num)` · `Td(label, num, primary, wide, hideStack)` · `EmptyRow`. | |
| 91 | +`components/ui/entity.tsx` — `EntityLink` · `EntityRow` (badge · name · org · key attributes · quality) · `keyAttributes(e)` · `QualityMark` · `EntityInline`. | |
| 92 | +`components/ui/live.tsx` (client) — `Dot(pulse)` · `LiveAgo(at)` (re-renders every 30 s). | |
| 93 | +`components/charts/charts.tsx` — pure SVG, theme-aware: `Sparkline` · `Bars` · `HBars` · `LineChart(series, yFormat, xTime)` · `Legend` · `stepPoints()` for price steps. | |
| 94 | +`components/changes/` — `ChangeRow(e, dense, showDate, live)` · `Delta` · `groupByDay` · `LoadMore` (cursor `before=`). | |
| 95 | +`components/listing/` — `FilterBar` (GET form → URL params) · `Facets` · `ListingLayout` (facets aside / mobile `<details>`) · `ActiveFilters` · `GenericListing(type, fetch?)`. | |
| 96 | +`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon) and `blocks.tsx`: | |
| 97 | +`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`. | |
| 98 | +`components/layout/` — header (Explore ▾, ⌘K search → `/search/suggest`, theme toggle), `MobileTabBar` (Home · Explore · Changes · Search · More), `SiteFooter`, `SearchDialog`, `ViewBeacon(path)`. | |
| 99 | +`components/meta/sitemap-data.ts` — sitemap shards (`static`, `<type>-<n>` of 5 000 from `GET /sitemap`). | |
| 100 | + | |
| 101 | +Do not fork these; extend with props or add new components in your own folder. | |
| 102 | + | |
| 103 | +## 5. Routes built | |
| 104 | + | |
| 105 | +`/` · `/search` · `/models` (+ facets, sort) · `/models/[slug]` · `/companies` · `/companies/[slug]` · `/[type]/[slug]` (providers, benchmarks, | |
| 106 | +hardware, papers, frameworks, datasets, tools, repositories) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` (types without a path) | |
| 107 | +· `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` listings · `/changes` · `/changes/[date]` · `/timeline` (basic) | |
| 108 | +· `/compare?ids=` (basic table) · `/methodology` · `/sources` · `/about` · `/developers` · `/bot` · `robots.ts` · `/sitemap.xml` + `/sitemap/[shard].xml` | |
| 109 | +· `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`. | |
| 110 | + | |
| 111 | +## 6. Left for the next agent (use the components above) | |
| 112 | + | |
| 113 | +- **Compare** picker UI (suggest-driven multi-select, pinned "compare tray", shareable URL), per-type dimension rendering, shared-benchmark rows. | |
| 114 | +- **Timeline** — richer `/timeline` (year scrubber, category chips, entity autocomplete, month density bars via `Bars`). | |
| 115 | +- **Hardware fit tool** `/hardware/fit` (form memory_gb/quant/context → `GET /hardware/fit`, table with `Estimated`, assumptions list). | |
| 116 | +- **Price index** `/prices` (`GET /prices/index` → `LineChart` median/min, movers via `ChangeRow`) and per-model price sparkline in `/models` rows. | |
| 117 | +- **Benchmarks** — leaderboard page per benchmark with config filter (`?config=`), `history=1`, result history chart per model. | |
| 118 | +- **Diff / history mode** — `/diff?a=&b=&scope=` and an "as of" toggle on entity pages (`GET /entities/{slug}/asof`, `/history?property=`) showing superseded claims. | |
| 119 | +- **Entity graph** (`GET /entities/{slug}/graph`) as an SVG neighbourhood on the Overview aside. | |
| 120 | +- **Admin** (`/admin`, token in a cookie, `x-aia-admin-token` from a server action; never expose it client-side): connectors, runs, errors, review queue, duplicates, LLM jobs, infrastructure. | |
| 121 | +- Listings for papers/frameworks/datasets/tools could gain type-specific tables (currently `EntityRow` lists via `GenericListing`). | |
| 122 | +- `manifest.ts`, per-entity `opengraph-image` (optional). | |
| 123 | + | |
| 124 | +## 7. Verification before you report | |
| 125 | + | |
| 126 | +1. `cd apps/web && pnpm typecheck` — zero errors. `pnpm build` must pass (Turbopack works; fall back to `next build --webpack` only if needed and say so). | |
| 127 | +2. Dev server on :8330 (`pnpm dev`). API on :8331 (`.venv/bin/aia api`). Run `node qa/screens.mjs` (add your routes to `PAGES`): | |
| 128 | + it checks HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth` at 390 and 1440 px, dark **and** light, | |
| 129 | + and that homepage counters equal `GET /api/v1/stats`. Screenshots land in `apps/web/qa/screens/`. | |
| 130 | +3. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest. | |
| 131 | +4. `curl -sI localhost:8330/<type>/does-not-exist` → 404. | |
| 132 | + | |
| 133 | +## 8. API notes observed live (2026-09-11) — keep in mind | |
| 134 | + | |
| 135 | +- Provider slugs can collide with company slugs (`anthropic` company vs `anthropic-2` provider "Anthropic API"). | |
| 136 | +- `EntitySummary.status` may be `available` while `attributes.status` is `active`; `StatusBadge` shows unknown values neutrally. | |
| 137 | +- `quality` and `counts` are often `{}` (score not computed yet) — `QualityMark` renders nothing then. | |
| 138 | +- `provenance[*]` currently lacks `source_name`; the UI falls back to the URL host. `extractor` is `llm` or `deterministic`. | |
| 139 | +- `/search` `query` is flat: `{ text, entity_type, openness, params_min, filters: { residual }, semantic }` — `understood()` in `search/page.tsx` renders it. | |
| 140 | +- `/methodology.event_types` items are `{ event_type, category, count, last_seen_at }` (no label/importance); `metrics` is `[]`. | |
added
pnpm-lock.yaml
+1169 −0
@@ -0,0 +1,1169 @@ | ||
| 1 | +lockfileVersion: '9.0' | |
| 2 | + | |
| 3 | +settings: | |
| 4 | + autoInstallPeers: true | |
| 5 | + excludeLinksFromLockfile: false | |
| 6 | + | |
| 7 | +importers: | |
| 8 | + | |
| 9 | + .: {} | |
| 10 | + | |
| 11 | + apps/web: | |
| 12 | + dependencies: | |
| 13 | + d3-array: | |
| 14 | + specifier: ^3.2.4 | |
| 15 | + version: 3.2.4 | |
| 16 | + d3-scale: | |
| 17 | + specifier: ^4.0.2 | |
| 18 | + version: 4.0.2 | |
| 19 | + d3-shape: | |
| 20 | + specifier: ^3.2.0 | |
| 21 | + version: 3.2.0 | |
| 22 | + geist: | |
| 23 | + specifier: ^1.5.1 | |
| 24 | + version: 1.7.2(next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) | |
| 25 | + lucide-react: | |
| 26 | + specifier: ^1.0.0 | |
| 27 | + version: 1.44.0(react@19.2.8) | |
| 28 | + next: | |
| 29 | + specifier: 16.3.4 | |
| 30 | + version: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 31 | + react: | |
| 32 | + specifier: 19.2.8 | |
| 33 | + version: 19.2.8 | |
| 34 | + react-dom: | |
| 35 | + specifier: 19.2.8 | |
| 36 | + version: 19.2.8(react@19.2.8) | |
| 37 | + server-only: | |
| 38 | + specifier: ^0.0.1 | |
| 39 | + version: 0.0.1 | |
| 40 | + devDependencies: | |
| 41 | + '@tailwindcss/postcss': | |
| 42 | + specifier: ^4 | |
| 43 | + version: 4.3.3 | |
| 44 | + '@types/d3-array': | |
| 45 | + specifier: ^3.2.1 | |
| 46 | + version: 3.2.2 | |
| 47 | + '@types/d3-scale': | |
| 48 | + specifier: ^4.0.9 | |
| 49 | + version: 4.0.9 | |
| 50 | + '@types/d3-shape': | |
| 51 | + specifier: ^3.1.7 | |
| 52 | + version: 3.2.0 | |
| 53 | + '@types/node': | |
| 54 | + specifier: ^24.0.0 | |
| 55 | + version: 24.13.4 | |
| 56 | + '@types/react': | |
| 57 | + specifier: ^19 | |
| 58 | + version: 19.3.0 | |
| 59 | + '@types/react-dom': | |
| 60 | + specifier: ^19 | |
| 61 | + version: 19.3.0(@types/react@19.3.0) | |
| 62 | + tailwindcss: | |
| 63 | + specifier: ^4 | |
| 64 | + version: 4.3.3 | |
| 65 | + typescript: | |
| 66 | + specifier: ^5.9.3 | |
| 67 | + version: 5.9.3 | |
| 68 | + | |
| 69 | +packages: | |
| 70 | + | |
| 71 | + '@alloc/quick-lru@5.3.0': | |
| 72 | + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} | |
| 73 | + engines: {node: '>=10'} | |
| 74 | + | |
| 75 | + '@emnapi/runtime@1.11.3': | |
| 76 | + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 77 | + | |
| 78 | + '@img/colour@1.1.0': | |
| 79 | + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 80 | + engines: {node: '>=18'} | |
| 81 | + | |
| 82 | + '@img/sharp-darwin-arm64@0.35.4': | |
| 83 | + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} | |
| 84 | + engines: {node: '>=20.9.0'} | |
| 85 | + cpu: [arm64] | |
| 86 | + os: [darwin] | |
| 87 | + | |
| 88 | + '@img/sharp-darwin-x64@0.35.4': | |
| 89 | + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} | |
| 90 | + engines: {node: '>=20.9.0'} | |
| 91 | + cpu: [x64] | |
| 92 | + os: [darwin] | |
| 93 | + | |
| 94 | + '@img/sharp-freebsd-wasm32@0.35.4': | |
| 95 | + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} | |
| 96 | + engines: {node: '>=20.9.0'} | |
| 97 | + os: [freebsd] | |
| 98 | + | |
| 99 | + '@img/sharp-libvips-darwin-arm64@1.3.3': | |
| 100 | + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} | |
| 101 | + cpu: [arm64] | |
| 102 | + os: [darwin] | |
| 103 | + | |
| 104 | + '@img/sharp-libvips-darwin-x64@1.3.3': | |
| 105 | + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} | |
| 106 | + cpu: [x64] | |
| 107 | + os: [darwin] | |
| 108 | + | |
| 109 | + '@img/sharp-libvips-linux-arm64@1.3.3': | |
| 110 | + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} | |
| 111 | + cpu: [arm64] | |
| 112 | + os: [linux] | |
| 113 | + libc: [glibc] | |
| 114 | + | |
| 115 | + '@img/sharp-libvips-linux-arm@1.3.3': | |
| 116 | + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} | |
| 117 | + cpu: [arm] | |
| 118 | + os: [linux] | |
| 119 | + libc: [glibc] | |
| 120 | + | |
| 121 | + '@img/sharp-libvips-linux-ppc64@1.3.3': | |
| 122 | + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} | |
| 123 | + cpu: [ppc64] | |
| 124 | + os: [linux] | |
| 125 | + libc: [glibc] | |
| 126 | + | |
| 127 | + '@img/sharp-libvips-linux-riscv64@1.3.3': | |
| 128 | + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} | |
| 129 | + cpu: [riscv64] | |
| 130 | + os: [linux] | |
| 131 | + libc: [glibc] | |
| 132 | + | |
| 133 | + '@img/sharp-libvips-linux-s390x@1.3.3': | |
| 134 | + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} | |
| 135 | + cpu: [s390x] | |
| 136 | + os: [linux] | |
| 137 | + libc: [glibc] | |
| 138 | + | |
| 139 | + '@img/sharp-libvips-linux-x64@1.3.3': | |
| 140 | + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} | |
| 141 | + cpu: [x64] | |
| 142 | + os: [linux] | |
| 143 | + libc: [glibc] | |
| 144 | + | |
| 145 | + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': | |
| 146 | + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} | |
| 147 | + cpu: [arm64] | |
| 148 | + os: [linux] | |
| 149 | + libc: [musl] | |
| 150 | + | |
| 151 | + '@img/sharp-libvips-linuxmusl-x64@1.3.3': | |
| 152 | + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} | |
| 153 | + cpu: [x64] | |
| 154 | + os: [linux] | |
| 155 | + libc: [musl] | |
| 156 | + | |
| 157 | + '@img/sharp-linux-arm64@0.35.4': | |
| 158 | + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} | |
| 159 | + engines: {node: '>=20.9.0'} | |
| 160 | + cpu: [arm64] | |
| 161 | + os: [linux] | |
| 162 | + libc: [glibc] | |
| 163 | + | |
| 164 | + '@img/sharp-linux-arm@0.35.4': | |
| 165 | + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} | |
| 166 | + engines: {node: '>=20.9.0'} | |
| 167 | + cpu: [arm] | |
| 168 | + os: [linux] | |
| 169 | + libc: [glibc] | |
| 170 | + | |
| 171 | + '@img/sharp-linux-ppc64@0.35.4': | |
| 172 | + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} | |
| 173 | + engines: {node: '>=20.9.0'} | |
| 174 | + cpu: [ppc64] | |
| 175 | + os: [linux] | |
| 176 | + libc: [glibc] | |
| 177 | + | |
| 178 | + '@img/sharp-linux-riscv64@0.35.4': | |
| 179 | + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} | |
| 180 | + engines: {node: '>=20.9.0'} | |
| 181 | + cpu: [riscv64] | |
| 182 | + os: [linux] | |
| 183 | + libc: [glibc] | |
| 184 | + | |
| 185 | + '@img/sharp-linux-s390x@0.35.4': | |
| 186 | + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} | |
| 187 | + engines: {node: '>=20.9.0'} | |
| 188 | + cpu: [s390x] | |
| 189 | + os: [linux] | |
| 190 | + libc: [glibc] | |
| 191 | + | |
| 192 | + '@img/sharp-linux-x64@0.35.4': | |
| 193 | + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} | |
| 194 | + engines: {node: '>=20.9.0'} | |
| 195 | + cpu: [x64] | |
| 196 | + os: [linux] | |
| 197 | + libc: [glibc] | |
| 198 | + | |
| 199 | + '@img/sharp-linuxmusl-arm64@0.35.4': | |
| 200 | + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} | |
| 201 | + engines: {node: '>=20.9.0'} | |
| 202 | + cpu: [arm64] | |
| 203 | + os: [linux] | |
| 204 | + libc: [musl] | |
| 205 | + | |
| 206 | + '@img/sharp-linuxmusl-x64@0.35.4': | |
| 207 | + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} | |
| 208 | + engines: {node: '>=20.9.0'} | |
| 209 | + cpu: [x64] | |
| 210 | + os: [linux] | |
| 211 | + libc: [musl] | |
| 212 | + | |
| 213 | + '@img/sharp-wasm32@0.35.4': | |
| 214 | + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} | |
| 215 | + engines: {node: '>=20.9.0'} | |
| 216 | + | |
| 217 | + '@img/sharp-webcontainers-wasm32@0.35.4': | |
| 218 | + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} | |
| 219 | + engines: {node: '>=20.9.0'} | |
| 220 | + cpu: [wasm32] | |
| 221 | + | |
| 222 | + '@img/sharp-win32-arm64@0.35.4': | |
| 223 | + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} | |
| 224 | + engines: {node: '>=20.9.0'} | |
| 225 | + cpu: [arm64] | |
| 226 | + os: [win32] | |
| 227 | + | |
| 228 | + '@img/sharp-win32-ia32@0.35.4': | |
| 229 | + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} | |
| 230 | + engines: {node: ^20.9.0} | |
| 231 | + cpu: [ia32] | |
| 232 | + os: [win32] | |
| 233 | + | |
| 234 | + '@img/sharp-win32-x64@0.35.4': | |
| 235 | + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} | |
| 236 | + engines: {node: '>=20.9.0'} | |
| 237 | + cpu: [x64] | |
| 238 | + os: [win32] | |
| 239 | + | |
| 240 | + '@jridgewell/gen-mapping@0.3.13': | |
| 241 | + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 242 | + | |
| 243 | + '@jridgewell/remapping@2.3.5': | |
| 244 | + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} | |
| 245 | + | |
| 246 | + '@jridgewell/resolve-uri@3.1.2': | |
| 247 | + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 248 | + engines: {node: '>=6.0.0'} | |
| 249 | + | |
| 250 | + '@jridgewell/sourcemap-codec@1.6.0': | |
| 251 | + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} | |
| 252 | + | |
| 253 | + '@jridgewell/trace-mapping@0.3.31': | |
| 254 | + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 255 | + | |
| 256 | + '@next/env@16.3.4': | |
| 257 | + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} | |
| 258 | + | |
| 259 | + '@next/swc-darwin-arm64@16.3.4': | |
| 260 | + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} | |
| 261 | + engines: {node: '>= 10'} | |
| 262 | + cpu: [arm64] | |
| 263 | + os: [darwin] | |
| 264 | + | |
| 265 | + '@next/swc-darwin-x64@16.3.4': | |
| 266 | + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} | |
| 267 | + engines: {node: '>= 10'} | |
| 268 | + cpu: [x64] | |
| 269 | + os: [darwin] | |
| 270 | + | |
| 271 | + '@next/swc-linux-arm64-gnu@16.3.4': | |
| 272 | + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} | |
| 273 | + engines: {node: '>= 10'} | |
| 274 | + cpu: [arm64] | |
| 275 | + os: [linux] | |
| 276 | + libc: [glibc] | |
| 277 | + | |
| 278 | + '@next/swc-linux-arm64-musl@16.3.4': | |
| 279 | + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} | |
| 280 | + engines: {node: '>= 10'} | |
| 281 | + cpu: [arm64] | |
| 282 | + os: [linux] | |
| 283 | + libc: [musl] | |
| 284 | + | |
| 285 | + '@next/swc-linux-x64-gnu@16.3.4': | |
| 286 | + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} | |
| 287 | + engines: {node: '>= 10'} | |
| 288 | + cpu: [x64] | |
| 289 | + os: [linux] | |
| 290 | + libc: [glibc] | |
| 291 | + | |
| 292 | + '@next/swc-linux-x64-musl@16.3.4': | |
| 293 | + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} | |
| 294 | + engines: {node: '>= 10'} | |
| 295 | + cpu: [x64] | |
| 296 | + os: [linux] | |
| 297 | + libc: [musl] | |
| 298 | + | |
| 299 | + '@next/swc-win32-arm64-msvc@16.3.4': | |
| 300 | + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} | |
| 301 | + engines: {node: '>= 10'} | |
| 302 | + cpu: [arm64] | |
| 303 | + os: [win32] | |
| 304 | + | |
| 305 | + '@next/swc-win32-x64-msvc@16.3.4': | |
| 306 | + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} | |
| 307 | + engines: {node: '>= 10'} | |
| 308 | + cpu: [x64] | |
| 309 | + os: [win32] | |
| 310 | + | |
| 311 | + '@swc/helpers@0.5.23': | |
| 312 | + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} | |
| 313 | + | |
| 314 | + '@tailwindcss/node@4.3.3': | |
| 315 | + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} | |
| 316 | + | |
| 317 | + '@tailwindcss/oxide-android-arm64@4.3.3': | |
| 318 | + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} | |
| 319 | + engines: {node: '>= 20'} | |
| 320 | + cpu: [arm64] | |
| 321 | + os: [android] | |
| 322 | + | |
| 323 | + '@tailwindcss/oxide-darwin-arm64@4.3.3': | |
| 324 | + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} | |
| 325 | + engines: {node: '>= 20'} | |
| 326 | + cpu: [arm64] | |
| 327 | + os: [darwin] | |
| 328 | + | |
| 329 | + '@tailwindcss/oxide-darwin-x64@4.3.3': | |
| 330 | + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} | |
| 331 | + engines: {node: '>= 20'} | |
| 332 | + cpu: [x64] | |
| 333 | + os: [darwin] | |
| 334 | + | |
| 335 | + '@tailwindcss/oxide-freebsd-x64@4.3.3': | |
| 336 | + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} | |
| 337 | + engines: {node: '>= 20'} | |
| 338 | + cpu: [x64] | |
| 339 | + os: [freebsd] | |
| 340 | + | |
| 341 | + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': | |
| 342 | + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} | |
| 343 | + engines: {node: '>= 20'} | |
| 344 | + cpu: [arm] | |
| 345 | + os: [linux] | |
| 346 | + | |
| 347 | + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': | |
| 348 | + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} | |
| 349 | + engines: {node: '>= 20'} | |
| 350 | + cpu: [arm64] | |
| 351 | + os: [linux] | |
| 352 | + libc: [glibc] | |
| 353 | + | |
| 354 | + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': | |
| 355 | + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} | |
| 356 | + engines: {node: '>= 20'} | |
| 357 | + cpu: [arm64] | |
| 358 | + os: [linux] | |
| 359 | + libc: [musl] | |
| 360 | + | |
| 361 | + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': | |
| 362 | + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} | |
| 363 | + engines: {node: '>= 20'} | |
| 364 | + cpu: [x64] | |
| 365 | + os: [linux] | |
| 366 | + libc: [glibc] | |
| 367 | + | |
| 368 | + '@tailwindcss/oxide-linux-x64-musl@4.3.3': | |
| 369 | + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} | |
| 370 | + engines: {node: '>= 20'} | |
| 371 | + cpu: [x64] | |
| 372 | + os: [linux] | |
| 373 | + libc: [musl] | |
| 374 | + | |
| 375 | + '@tailwindcss/oxide-wasm32-wasi@4.3.3': | |
| 376 | + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} | |
| 377 | + engines: {node: '>=14.0.0'} | |
| 378 | + cpu: [wasm32] | |
| 379 | + bundledDependencies: | |
| 380 | + - '@napi-rs/wasm-runtime' | |
| 381 | + - '@emnapi/core' | |
| 382 | + - '@emnapi/runtime' | |
| 383 | + - '@tybys/wasm-util' | |
| 384 | + - '@emnapi/wasi-threads' | |
| 385 | + - tslib | |
| 386 | + | |
| 387 | + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': | |
| 388 | + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} | |
| 389 | + engines: {node: '>= 20'} | |
| 390 | + cpu: [arm64] | |
| 391 | + os: [win32] | |
| 392 | + | |
| 393 | + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': | |
| 394 | + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} | |
| 395 | + engines: {node: '>= 20'} | |
| 396 | + cpu: [x64] | |
| 397 | + os: [win32] | |
| 398 | + | |
| 399 | + '@tailwindcss/oxide@4.3.3': | |
| 400 | + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} | |
| 401 | + engines: {node: '>= 20'} | |
| 402 | + | |
| 403 | + '@tailwindcss/postcss@4.3.3': | |
| 404 | + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} | |
| 405 | + | |
| 406 | + '@types/d3-array@3.2.2': | |
| 407 | + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} | |
| 408 | + | |
| 409 | + '@types/d3-path@3.1.1': | |
| 410 | + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} | |
| 411 | + | |
| 412 | + '@types/d3-scale@4.0.9': | |
| 413 | + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} | |
| 414 | + | |
| 415 | + '@types/d3-shape@3.2.0': | |
| 416 | + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} | |
| 417 | + | |
| 418 | + '@types/d3-time@3.0.4': | |
| 419 | + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} | |
| 420 | + | |
| 421 | + '@types/node@24.13.4': | |
| 422 | + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} | |
| 423 | + | |
| 424 | + '@types/react-dom@19.3.0': | |
| 425 | + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} | |
| 426 | + peerDependencies: | |
| 427 | + '@types/react': ^19.3.0 | |
| 428 | + | |
| 429 | + '@types/react@19.3.0': | |
| 430 | + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} | |
| 431 | + | |
| 432 | + baseline-browser-mapping@2.11.22: | |
| 433 | + resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==} | |
| 434 | + engines: {node: '>=6.0.0'} | |
| 435 | + hasBin: true | |
| 436 | + | |
| 437 | + caniuse-lite@1.0.30001810: | |
| 438 | + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} | |
| 439 | + | |
| 440 | + client-only@0.0.1: | |
| 441 | + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 442 | + | |
| 443 | + csstype@3.2.3: | |
| 444 | + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 445 | + | |
| 446 | + d3-array@3.2.4: | |
| 447 | + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} | |
| 448 | + engines: {node: '>=12'} | |
| 449 | + | |
| 450 | + d3-color@3.1.0: | |
| 451 | + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} | |
| 452 | + engines: {node: '>=12'} | |
| 453 | + | |
| 454 | + d3-format@3.1.2: | |
| 455 | + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} | |
| 456 | + engines: {node: '>=12'} | |
| 457 | + | |
| 458 | + d3-interpolate@3.0.1: | |
| 459 | + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} | |
| 460 | + engines: {node: '>=12'} | |
| 461 | + | |
| 462 | + d3-path@3.1.0: | |
| 463 | + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} | |
| 464 | + engines: {node: '>=12'} | |
| 465 | + | |
| 466 | + d3-scale@4.0.2: | |
| 467 | + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} | |
| 468 | + engines: {node: '>=12'} | |
| 469 | + | |
| 470 | + d3-shape@3.2.0: | |
| 471 | + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} | |
| 472 | + engines: {node: '>=12'} | |
| 473 | + | |
| 474 | + d3-time-format@4.1.0: | |
| 475 | + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} | |
| 476 | + engines: {node: '>=12'} | |
| 477 | + | |
| 478 | + d3-time@3.1.0: | |
| 479 | + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} | |
| 480 | + engines: {node: '>=12'} | |
| 481 | + | |
| 482 | + detect-libc@2.1.2: | |
| 483 | + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 484 | + engines: {node: '>=8'} | |
| 485 | + | |
| 486 | + enhanced-resolve@5.24.5: | |
| 487 | + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} | |
| 488 | + engines: {node: '>=10.13.0'} | |
| 489 | + | |
| 490 | + geist@1.7.2: | |
| 491 | + resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==} | |
| 492 | + peerDependencies: | |
| 493 | + next: '>=13.2.0' | |
| 494 | + | |
| 495 | + graceful-fs@4.2.11: | |
| 496 | + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} | |
| 497 | + | |
| 498 | + internmap@2.0.3: | |
| 499 | + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} | |
| 500 | + engines: {node: '>=12'} | |
| 501 | + | |
| 502 | + jiti@2.7.0: | |
| 503 | + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} | |
| 504 | + hasBin: true | |
| 505 | + | |
| 506 | + lightningcss-android-arm64@1.32.0: | |
| 507 | + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} | |
| 508 | + engines: {node: '>= 12.0.0'} | |
| 509 | + cpu: [arm64] | |
| 510 | + os: [android] | |
| 511 | + | |
| 512 | + lightningcss-darwin-arm64@1.32.0: | |
| 513 | + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} | |
| 514 | + engines: {node: '>= 12.0.0'} | |
| 515 | + cpu: [arm64] | |
| 516 | + os: [darwin] | |
| 517 | + | |
| 518 | + lightningcss-darwin-x64@1.32.0: | |
| 519 | + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} | |
| 520 | + engines: {node: '>= 12.0.0'} | |
| 521 | + cpu: [x64] | |
| 522 | + os: [darwin] | |
| 523 | + | |
| 524 | + lightningcss-freebsd-x64@1.32.0: | |
| 525 | + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} | |
| 526 | + engines: {node: '>= 12.0.0'} | |
| 527 | + cpu: [x64] | |
| 528 | + os: [freebsd] | |
| 529 | + | |
| 530 | + lightningcss-linux-arm-gnueabihf@1.32.0: | |
| 531 | + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} | |
| 532 | + engines: {node: '>= 12.0.0'} | |
| 533 | + cpu: [arm] | |
| 534 | + os: [linux] | |
| 535 | + | |
| 536 | + lightningcss-linux-arm64-gnu@1.32.0: | |
| 537 | + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} | |
| 538 | + engines: {node: '>= 12.0.0'} | |
| 539 | + cpu: [arm64] | |
| 540 | + os: [linux] | |
| 541 | + libc: [glibc] | |
| 542 | + | |
| 543 | + lightningcss-linux-arm64-musl@1.32.0: | |
| 544 | + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} | |
| 545 | + engines: {node: '>= 12.0.0'} | |
| 546 | + cpu: [arm64] | |
| 547 | + os: [linux] | |
| 548 | + libc: [musl] | |
| 549 | + | |
| 550 | + lightningcss-linux-x64-gnu@1.32.0: | |
| 551 | + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} | |
| 552 | + engines: {node: '>= 12.0.0'} | |
| 553 | + cpu: [x64] | |
| 554 | + os: [linux] | |
| 555 | + libc: [glibc] | |
| 556 | + | |
| 557 | + lightningcss-linux-x64-musl@1.32.0: | |
| 558 | + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} | |
| 559 | + engines: {node: '>= 12.0.0'} | |
| 560 | + cpu: [x64] | |
| 561 | + os: [linux] | |
| 562 | + libc: [musl] | |
| 563 | + | |
| 564 | + lightningcss-win32-arm64-msvc@1.32.0: | |
| 565 | + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} | |
| 566 | + engines: {node: '>= 12.0.0'} | |
| 567 | + cpu: [arm64] | |
| 568 | + os: [win32] | |
| 569 | + | |
| 570 | + lightningcss-win32-x64-msvc@1.32.0: | |
| 571 | + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} | |
| 572 | + engines: {node: '>= 12.0.0'} | |
| 573 | + cpu: [x64] | |
| 574 | + os: [win32] | |
| 575 | + | |
| 576 | + lightningcss@1.32.0: | |
| 577 | + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} | |
| 578 | + engines: {node: '>= 12.0.0'} | |
| 579 | + | |
| 580 | + lucide-react@1.44.0: | |
| 581 | + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} | |
| 582 | + peerDependencies: | |
| 583 | + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 | |
| 584 | + | |
| 585 | + magic-string@0.30.21: | |
| 586 | + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} | |
| 587 | + | |
| 588 | + nanoid@3.3.19: | |
| 589 | + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} | |
| 590 | + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 591 | + hasBin: true | |
| 592 | + | |
| 593 | + next@16.3.4: | |
| 594 | + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} | |
| 595 | + engines: {node: '>=20.9.0'} | |
| 596 | + hasBin: true | |
| 597 | + peerDependencies: | |
| 598 | + '@opentelemetry/api': ^1.1.0 | |
| 599 | + '@playwright/test': ^1.51.1 | |
| 600 | + babel-plugin-react-compiler: '*' | |
| 601 | + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 602 | + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 603 | + sass: ^1.3.0 | |
| 604 | + peerDependenciesMeta: | |
| 605 | + '@opentelemetry/api': | |
| 606 | + optional: true | |
| 607 | + '@playwright/test': | |
| 608 | + optional: true | |
| 609 | + babel-plugin-react-compiler: | |
| 610 | + optional: true | |
| 611 | + sass: | |
| 612 | + optional: true | |
| 613 | + | |
| 614 | + picocolors@1.1.1: | |
| 615 | + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 616 | + | |
| 617 | + postcss@8.5.23: | |
| 618 | + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} | |
| 619 | + engines: {node: ^10 || ^12 || >=14} | |
| 620 | + | |
| 621 | + postcss@8.5.28: | |
| 622 | + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} | |
| 623 | + engines: {node: ^10 || ^12 || >=14} | |
| 624 | + | |
| 625 | + react-dom@19.2.8: | |
| 626 | + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} | |
| 627 | + peerDependencies: | |
| 628 | + react: ^19.2.8 | |
| 629 | + | |
| 630 | + react@19.2.8: | |
| 631 | + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} | |
| 632 | + engines: {node: '>=0.10.0'} | |
| 633 | + | |
| 634 | + scheduler@0.27.0: | |
| 635 | + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} | |
| 636 | + | |
| 637 | + semver@7.8.5: | |
| 638 | + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 639 | + engines: {node: '>=10'} | |
| 640 | + hasBin: true | |
| 641 | + | |
| 642 | + server-only@0.0.1: | |
| 643 | + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} | |
| 644 | + | |
| 645 | + sharp@0.35.4: | |
| 646 | + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} | |
| 647 | + engines: {node: '>=20.9.0'} | |
| 648 | + peerDependencies: | |
| 649 | + '@types/node': '*' | |
| 650 | + peerDependenciesMeta: | |
| 651 | + '@types/node': | |
| 652 | + optional: true | |
| 653 | + | |
| 654 | + source-map-js@1.2.1: | |
| 655 | + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 656 | + engines: {node: '>=0.10.0'} | |
| 657 | + | |
| 658 | + styled-jsx@5.1.6: | |
| 659 | + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 660 | + engines: {node: '>= 12.0.0'} | |
| 661 | + peerDependencies: | |
| 662 | + '@babel/core': '*' | |
| 663 | + babel-plugin-macros: '*' | |
| 664 | + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 665 | + peerDependenciesMeta: | |
| 666 | + '@babel/core': | |
| 667 | + optional: true | |
| 668 | + babel-plugin-macros: | |
| 669 | + optional: true | |
| 670 | + | |
| 671 | + tailwindcss@4.3.3: | |
| 672 | + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} | |
| 673 | + | |
| 674 | + tapable@2.3.3: | |
| 675 | + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} | |
| 676 | + engines: {node: '>=6'} | |
| 677 | + | |
| 678 | + tslib@2.8.1: | |
| 679 | + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 680 | + | |
| 681 | + typescript@5.9.3: | |
| 682 | + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 683 | + engines: {node: '>=14.17'} | |
| 684 | + hasBin: true | |
| 685 | + | |
| 686 | + undici-types@7.18.2: | |
| 687 | + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} | |
| 688 | + | |
| 689 | +snapshots: | |
| 690 | + | |
| 691 | + '@alloc/quick-lru@5.3.0': {} | |
| 692 | + | |
| 693 | + '@emnapi/runtime@1.11.3': | |
| 694 | + dependencies: | |
| 695 | + tslib: 2.8.1 | |
| 696 | + optional: true | |
| 697 | + | |
| 698 | + '@img/colour@1.1.0': | |
| 699 | + optional: true | |
| 700 | + | |
| 701 | + '@img/sharp-darwin-arm64@0.35.4': | |
| 702 | + optionalDependencies: | |
| 703 | + '@img/sharp-libvips-darwin-arm64': 1.3.3 | |
| 704 | + optional: true | |
| 705 | + | |
| 706 | + '@img/sharp-darwin-x64@0.35.4': | |
| 707 | + optionalDependencies: | |
| 708 | + '@img/sharp-libvips-darwin-x64': 1.3.3 | |
| 709 | + optional: true | |
| 710 | + | |
| 711 | + '@img/sharp-freebsd-wasm32@0.35.4': | |
| 712 | + dependencies: | |
| 713 | + '@img/sharp-wasm32': 0.35.4 | |
| 714 | + optional: true | |
| 715 | + | |
| 716 | + '@img/sharp-libvips-darwin-arm64@1.3.3': | |
| 717 | + optional: true | |
| 718 | + | |
| 719 | + '@img/sharp-libvips-darwin-x64@1.3.3': | |
| 720 | + optional: true | |
| 721 | + | |
| 722 | + '@img/sharp-libvips-linux-arm64@1.3.3': | |
| 723 | + optional: true | |
| 724 | + | |
| 725 | + '@img/sharp-libvips-linux-arm@1.3.3': | |
| 726 | + optional: true | |
| 727 | + | |
| 728 | + '@img/sharp-libvips-linux-ppc64@1.3.3': | |
| 729 | + optional: true | |
| 730 | + | |
| 731 | + '@img/sharp-libvips-linux-riscv64@1.3.3': | |
| 732 | + optional: true | |
| 733 | + | |
| 734 | + '@img/sharp-libvips-linux-s390x@1.3.3': | |
| 735 | + optional: true | |
| 736 | + | |
| 737 | + '@img/sharp-libvips-linux-x64@1.3.3': | |
| 738 | + optional: true | |
| 739 | + | |
| 740 | + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': | |
| 741 | + optional: true | |
| 742 | + | |
| 743 | + '@img/sharp-libvips-linuxmusl-x64@1.3.3': | |
| 744 | + optional: true | |
| 745 | + | |
| 746 | + '@img/sharp-linux-arm64@0.35.4': | |
| 747 | + optionalDependencies: | |
| 748 | + '@img/sharp-libvips-linux-arm64': 1.3.3 | |
| 749 | + optional: true | |
| 750 | + | |
| 751 | + '@img/sharp-linux-arm@0.35.4': | |
| 752 | + optionalDependencies: | |
| 753 | + '@img/sharp-libvips-linux-arm': 1.3.3 | |
| 754 | + optional: true | |
| 755 | + | |
| 756 | + '@img/sharp-linux-ppc64@0.35.4': | |
| 757 | + optionalDependencies: | |
| 758 | + '@img/sharp-libvips-linux-ppc64': 1.3.3 | |
| 759 | + optional: true | |
| 760 | + | |
| 761 | + '@img/sharp-linux-riscv64@0.35.4': | |
| 762 | + optionalDependencies: | |
| 763 | + '@img/sharp-libvips-linux-riscv64': 1.3.3 | |
| 764 | + optional: true | |
| 765 | + | |
| 766 | + '@img/sharp-linux-s390x@0.35.4': | |
| 767 | + optionalDependencies: | |
| 768 | + '@img/sharp-libvips-linux-s390x': 1.3.3 | |
| 769 | + optional: true | |
| 770 | + | |
| 771 | + '@img/sharp-linux-x64@0.35.4': | |
| 772 | + optionalDependencies: | |
| 773 | + '@img/sharp-libvips-linux-x64': 1.3.3 | |
| 774 | + optional: true | |
| 775 | + | |
| 776 | + '@img/sharp-linuxmusl-arm64@0.35.4': | |
| 777 | + optionalDependencies: | |
| 778 | + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 | |
| 779 | + optional: true | |
| 780 | + | |
| 781 | + '@img/sharp-linuxmusl-x64@0.35.4': | |
| 782 | + optionalDependencies: | |
| 783 | + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 | |
| 784 | + optional: true | |
| 785 | + | |
| 786 | + '@img/sharp-wasm32@0.35.4': | |
| 787 | + dependencies: | |
| 788 | + '@emnapi/runtime': 1.11.3 | |
| 789 | + optional: true | |
| 790 | + | |
| 791 | + '@img/sharp-webcontainers-wasm32@0.35.4': | |
| 792 | + dependencies: | |
| 793 | + '@img/sharp-wasm32': 0.35.4 | |
| 794 | + optional: true | |
| 795 | + | |
| 796 | + '@img/sharp-win32-arm64@0.35.4': | |
| 797 | + optional: true | |
| 798 | + | |
| 799 | + '@img/sharp-win32-ia32@0.35.4': | |
| 800 | + optional: true | |
| 801 | + | |
| 802 | + '@img/sharp-win32-x64@0.35.4': | |
| 803 | + optional: true | |
| 804 | + | |
| 805 | + '@jridgewell/gen-mapping@0.3.13': | |
| 806 | + dependencies: | |
| 807 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 808 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 809 | + | |
| 810 | + '@jridgewell/remapping@2.3.5': | |
| 811 | + dependencies: | |
| 812 | + '@jridgewell/gen-mapping': 0.3.13 | |
| 813 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 814 | + | |
| 815 | + '@jridgewell/resolve-uri@3.1.2': {} | |
| 816 | + | |
| 817 | + '@jridgewell/sourcemap-codec@1.6.0': {} | |
| 818 | + | |
| 819 | + '@jridgewell/trace-mapping@0.3.31': | |
| 820 | + dependencies: | |
| 821 | + '@jridgewell/resolve-uri': 3.1.2 | |
| 822 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 823 | + | |
| 824 | + '@next/env@16.3.4': {} | |
| 825 | + | |
| 826 | + '@next/swc-darwin-arm64@16.3.4': | |
| 827 | + optional: true | |
| 828 | + | |
| 829 | + '@next/swc-darwin-x64@16.3.4': | |
| 830 | + optional: true | |
| 831 | + | |
| 832 | + '@next/swc-linux-arm64-gnu@16.3.4': | |
| 833 | + optional: true | |
| 834 | + | |
| 835 | + '@next/swc-linux-arm64-musl@16.3.4': | |
| 836 | + optional: true | |
| 837 | + | |
| 838 | + '@next/swc-linux-x64-gnu@16.3.4': | |
| 839 | + optional: true | |
| 840 | + | |
| 841 | + '@next/swc-linux-x64-musl@16.3.4': | |
| 842 | + optional: true | |
| 843 | + | |
| 844 | + '@next/swc-win32-arm64-msvc@16.3.4': | |
| 845 | + optional: true | |
| 846 | + | |
| 847 | + '@next/swc-win32-x64-msvc@16.3.4': | |
| 848 | + optional: true | |
| 849 | + | |
| 850 | + '@swc/helpers@0.5.23': | |
| 851 | + dependencies: | |
| 852 | + tslib: 2.8.1 | |
| 853 | + | |
| 854 | + '@tailwindcss/node@4.3.3': | |
| 855 | + dependencies: | |
| 856 | + '@jridgewell/remapping': 2.3.5 | |
| 857 | + enhanced-resolve: 5.24.5 | |
| 858 | + jiti: 2.7.0 | |
| 859 | + lightningcss: 1.32.0 | |
| 860 | + magic-string: 0.30.21 | |
| 861 | + source-map-js: 1.2.1 | |
| 862 | + tailwindcss: 4.3.3 | |
| 863 | + | |
| 864 | + '@tailwindcss/oxide-android-arm64@4.3.3': | |
| 865 | + optional: true | |
| 866 | + | |
| 867 | + '@tailwindcss/oxide-darwin-arm64@4.3.3': | |
| 868 | + optional: true | |
| 869 | + | |
| 870 | + '@tailwindcss/oxide-darwin-x64@4.3.3': | |
| 871 | + optional: true | |
| 872 | + | |
| 873 | + '@tailwindcss/oxide-freebsd-x64@4.3.3': | |
| 874 | + optional: true | |
| 875 | + | |
| 876 | + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': | |
| 877 | + optional: true | |
| 878 | + | |
| 879 | + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': | |
| 880 | + optional: true | |
| 881 | + | |
| 882 | + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': | |
| 883 | + optional: true | |
| 884 | + | |
| 885 | + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': | |
| 886 | + optional: true | |
| 887 | + | |
| 888 | + '@tailwindcss/oxide-linux-x64-musl@4.3.3': | |
| 889 | + optional: true | |
| 890 | + | |
| 891 | + '@tailwindcss/oxide-wasm32-wasi@4.3.3': | |
| 892 | + optional: true | |
| 893 | + | |
| 894 | + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': | |
| 895 | + optional: true | |
| 896 | + | |
| 897 | + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': | |
| 898 | + optional: true | |
| 899 | + | |
| 900 | + '@tailwindcss/oxide@4.3.3': | |
| 901 | + optionalDependencies: | |
| 902 | + '@tailwindcss/oxide-android-arm64': 4.3.3 | |
| 903 | + '@tailwindcss/oxide-darwin-arm64': 4.3.3 | |
| 904 | + '@tailwindcss/oxide-darwin-x64': 4.3.3 | |
| 905 | + '@tailwindcss/oxide-freebsd-x64': 4.3.3 | |
| 906 | + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 | |
| 907 | + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 | |
| 908 | + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 | |
| 909 | + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 | |
| 910 | + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 | |
| 911 | + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 | |
| 912 | + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 | |
| 913 | + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 | |
| 914 | + | |
| 915 | + '@tailwindcss/postcss@4.3.3': | |
| 916 | + dependencies: | |
| 917 | + '@alloc/quick-lru': 5.3.0 | |
| 918 | + '@tailwindcss/node': 4.3.3 | |
| 919 | + '@tailwindcss/oxide': 4.3.3 | |
| 920 | + postcss: 8.5.28 | |
| 921 | + tailwindcss: 4.3.3 | |
| 922 | + | |
| 923 | + '@types/d3-array@3.2.2': {} | |
| 924 | + | |
| 925 | + '@types/d3-path@3.1.1': {} | |
| 926 | + | |
| 927 | + '@types/d3-scale@4.0.9': | |
| 928 | + dependencies: | |
| 929 | + '@types/d3-time': 3.0.4 | |
| 930 | + | |
| 931 | + '@types/d3-shape@3.2.0': | |
| 932 | + dependencies: | |
| 933 | + '@types/d3-path': 3.1.1 | |
| 934 | + | |
| 935 | + '@types/d3-time@3.0.4': {} | |
| 936 | + | |
| 937 | + '@types/node@24.13.4': | |
| 938 | + dependencies: | |
| 939 | + undici-types: 7.18.2 | |
| 940 | + | |
| 941 | + '@types/react-dom@19.3.0(@types/react@19.3.0)': | |
| 942 | + dependencies: | |
| 943 | + '@types/react': 19.3.0 | |
| 944 | + | |
| 945 | + '@types/react@19.3.0': | |
| 946 | + dependencies: | |
| 947 | + csstype: 3.2.3 | |
| 948 | + | |
| 949 | + baseline-browser-mapping@2.11.22: {} | |
| 950 | + | |
| 951 | + caniuse-lite@1.0.30001810: {} | |
| 952 | + | |
| 953 | + client-only@0.0.1: {} | |
| 954 | + | |
| 955 | + csstype@3.2.3: {} | |
| 956 | + | |
| 957 | + d3-array@3.2.4: | |
| 958 | + dependencies: | |
| 959 | + internmap: 2.0.3 | |
| 960 | + | |
| 961 | + d3-color@3.1.0: {} | |
| 962 | + | |
| 963 | + d3-format@3.1.2: {} | |
| 964 | + | |
| 965 | + d3-interpolate@3.0.1: | |
| 966 | + dependencies: | |
| 967 | + d3-color: 3.1.0 | |
| 968 | + | |
| 969 | + d3-path@3.1.0: {} | |
| 970 | + | |
| 971 | + d3-scale@4.0.2: | |
| 972 | + dependencies: | |
| 973 | + d3-array: 3.2.4 | |
| 974 | + d3-format: 3.1.2 | |
| 975 | + d3-interpolate: 3.0.1 | |
| 976 | + d3-time: 3.1.0 | |
| 977 | + d3-time-format: 4.1.0 | |
| 978 | + | |
| 979 | + d3-shape@3.2.0: | |
| 980 | + dependencies: | |
| 981 | + d3-path: 3.1.0 | |
| 982 | + | |
| 983 | + d3-time-format@4.1.0: | |
| 984 | + dependencies: | |
| 985 | + d3-time: 3.1.0 | |
| 986 | + | |
| 987 | + d3-time@3.1.0: | |
| 988 | + dependencies: | |
| 989 | + d3-array: 3.2.4 | |
| 990 | + | |
| 991 | + detect-libc@2.1.2: {} | |
| 992 | + | |
| 993 | + enhanced-resolve@5.24.5: | |
| 994 | + dependencies: | |
| 995 | + graceful-fs: 4.2.11 | |
| 996 | + tapable: 2.3.3 | |
| 997 | + | |
| 998 | + geist@1.7.2(next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): | |
| 999 | + dependencies: | |
| 1000 | + next: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) | |
| 1001 | + | |
| 1002 | + graceful-fs@4.2.11: {} | |
| 1003 | + | |
| 1004 | + internmap@2.0.3: {} | |
| 1005 | + | |
| 1006 | + jiti@2.7.0: {} | |
| 1007 | + | |
| 1008 | + lightningcss-android-arm64@1.32.0: | |
| 1009 | + optional: true | |
| 1010 | + | |
| 1011 | + lightningcss-darwin-arm64@1.32.0: | |
| 1012 | + optional: true | |
| 1013 | + | |
| 1014 | + lightningcss-darwin-x64@1.32.0: | |
| 1015 | + optional: true | |
| 1016 | + | |
| 1017 | + lightningcss-freebsd-x64@1.32.0: | |
| 1018 | + optional: true | |
| 1019 | + | |
| 1020 | + lightningcss-linux-arm-gnueabihf@1.32.0: | |
| 1021 | + optional: true | |
| 1022 | + | |
| 1023 | + lightningcss-linux-arm64-gnu@1.32.0: | |
| 1024 | + optional: true | |
| 1025 | + | |
| 1026 | + lightningcss-linux-arm64-musl@1.32.0: | |
| 1027 | + optional: true | |
| 1028 | + | |
| 1029 | + lightningcss-linux-x64-gnu@1.32.0: | |
| 1030 | + optional: true | |
| 1031 | + | |
| 1032 | + lightningcss-linux-x64-musl@1.32.0: | |
| 1033 | + optional: true | |
| 1034 | + | |
| 1035 | + lightningcss-win32-arm64-msvc@1.32.0: | |
| 1036 | + optional: true | |
| 1037 | + | |
| 1038 | + lightningcss-win32-x64-msvc@1.32.0: | |
| 1039 | + optional: true | |
| 1040 | + | |
| 1041 | + lightningcss@1.32.0: | |
| 1042 | + dependencies: | |
| 1043 | + detect-libc: 2.1.2 | |
| 1044 | + optionalDependencies: | |
| 1045 | + lightningcss-android-arm64: 1.32.0 | |
| 1046 | + lightningcss-darwin-arm64: 1.32.0 | |
| 1047 | + lightningcss-darwin-x64: 1.32.0 | |
| 1048 | + lightningcss-freebsd-x64: 1.32.0 | |
| 1049 | + lightningcss-linux-arm-gnueabihf: 1.32.0 | |
| 1050 | + lightningcss-linux-arm64-gnu: 1.32.0 | |
| 1051 | + lightningcss-linux-arm64-musl: 1.32.0 | |
| 1052 | + lightningcss-linux-x64-gnu: 1.32.0 | |
| 1053 | + lightningcss-linux-x64-musl: 1.32.0 | |
| 1054 | + lightningcss-win32-arm64-msvc: 1.32.0 | |
| 1055 | + lightningcss-win32-x64-msvc: 1.32.0 | |
| 1056 | + | |
| 1057 | + lucide-react@1.44.0(react@19.2.8): | |
| 1058 | + dependencies: | |
| 1059 | + react: 19.2.8 | |
| 1060 | + | |
| 1061 | + magic-string@0.30.21: | |
| 1062 | + dependencies: | |
| 1063 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 1064 | + | |
| 1065 | + nanoid@3.3.19: {} | |
| 1066 | + | |
| 1067 | + next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): | |
| 1068 | + dependencies: | |
| 1069 | + '@next/env': 16.3.4 | |
| 1070 | + '@swc/helpers': 0.5.23 | |
| 1071 | + baseline-browser-mapping: 2.11.22 | |
| 1072 | + caniuse-lite: 1.0.30001810 | |
| 1073 | + postcss: 8.5.23 | |
| 1074 | + react: 19.2.8 | |
| 1075 | + react-dom: 19.2.8(react@19.2.8) | |
| 1076 | + styled-jsx: 5.1.6(react@19.2.8) | |
| 1077 | + optionalDependencies: | |
| 1078 | + '@next/swc-darwin-arm64': 16.3.4 | |
| 1079 | + '@next/swc-darwin-x64': 16.3.4 | |
| 1080 | + '@next/swc-linux-arm64-gnu': 16.3.4 | |
| 1081 | + '@next/swc-linux-arm64-musl': 16.3.4 | |
| 1082 | + '@next/swc-linux-x64-gnu': 16.3.4 | |
| 1083 | + '@next/swc-linux-x64-musl': 16.3.4 | |
| 1084 | + '@next/swc-win32-arm64-msvc': 16.3.4 | |
| 1085 | + '@next/swc-win32-x64-msvc': 16.3.4 | |
| 1086 | + sharp: 0.35.4(@types/node@24.13.4) | |
| 1087 | + transitivePeerDependencies: | |
| 1088 | + - '@babel/core' | |
| 1089 | + - '@types/node' | |
| 1090 | + - babel-plugin-macros | |
| 1091 | + | |
| 1092 | + picocolors@1.1.1: {} | |
| 1093 | + | |
| 1094 | + postcss@8.5.23: | |
| 1095 | + dependencies: | |
| 1096 | + nanoid: 3.3.19 | |
| 1097 | + picocolors: 1.1.1 | |
| 1098 | + source-map-js: 1.2.1 | |
| 1099 | + | |
| 1100 | + postcss@8.5.28: | |
| 1101 | + dependencies: | |
| 1102 | + nanoid: 3.3.19 | |
| 1103 | + picocolors: 1.1.1 | |
| 1104 | + source-map-js: 1.2.1 | |
| 1105 | + | |
| 1106 | + react-dom@19.2.8(react@19.2.8): | |
| 1107 | + dependencies: | |
| 1108 | + react: 19.2.8 | |
| 1109 | + scheduler: 0.27.0 | |
| 1110 | + | |
| 1111 | + react@19.2.8: {} | |
| 1112 | + | |
| 1113 | + scheduler@0.27.0: {} | |
| 1114 | + | |
| 1115 | + semver@7.8.5: | |
| 1116 | + optional: true | |
| 1117 | + | |
| 1118 | + server-only@0.0.1: {} | |
| 1119 | + | |
| 1120 | + sharp@0.35.4(@types/node@24.13.4): | |
| 1121 | + dependencies: | |
| 1122 | + '@img/colour': 1.1.0 | |
| 1123 | + detect-libc: 2.1.2 | |
| 1124 | + semver: 7.8.5 | |
| 1125 | + optionalDependencies: | |
| 1126 | + '@img/sharp-darwin-arm64': 0.35.4 | |
| 1127 | + '@img/sharp-darwin-x64': 0.35.4 | |
| 1128 | + '@img/sharp-freebsd-wasm32': 0.35.4 | |
| 1129 | + '@img/sharp-libvips-darwin-arm64': 1.3.3 | |
| 1130 | + '@img/sharp-libvips-darwin-x64': 1.3.3 | |
| 1131 | + '@img/sharp-libvips-linux-arm': 1.3.3 | |
| 1132 | + '@img/sharp-libvips-linux-arm64': 1.3.3 | |
| 1133 | + '@img/sharp-libvips-linux-ppc64': 1.3.3 | |
| 1134 | + '@img/sharp-libvips-linux-riscv64': 1.3.3 | |
| 1135 | + '@img/sharp-libvips-linux-s390x': 1.3.3 | |
| 1136 | + '@img/sharp-libvips-linux-x64': 1.3.3 | |
| 1137 | + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 | |
| 1138 | + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 | |
| 1139 | + '@img/sharp-linux-arm': 0.35.4 | |
| 1140 | + '@img/sharp-linux-arm64': 0.35.4 | |
| 1141 | + '@img/sharp-linux-ppc64': 0.35.4 | |
| 1142 | + '@img/sharp-linux-riscv64': 0.35.4 | |
| 1143 | + '@img/sharp-linux-s390x': 0.35.4 | |
| 1144 | + '@img/sharp-linux-x64': 0.35.4 | |
| 1145 | + '@img/sharp-linuxmusl-arm64': 0.35.4 | |
| 1146 | + '@img/sharp-linuxmusl-x64': 0.35.4 | |
| 1147 | + '@img/sharp-webcontainers-wasm32': 0.35.4 | |
| 1148 | + '@img/sharp-win32-arm64': 0.35.4 | |
| 1149 | + '@img/sharp-win32-ia32': 0.35.4 | |
| 1150 | + '@img/sharp-win32-x64': 0.35.4 | |
| 1151 | + '@types/node': 24.13.4 | |
| 1152 | + optional: true | |
| 1153 | + | |
| 1154 | + source-map-js@1.2.1: {} | |
| 1155 | + | |
| 1156 | + styled-jsx@5.1.6(react@19.2.8): | |
| 1157 | + dependencies: | |
| 1158 | + client-only: 0.0.1 | |
| 1159 | + react: 19.2.8 | |
| 1160 | + | |
| 1161 | + tailwindcss@4.3.3: {} | |
| 1162 | + | |
| 1163 | + tapable@2.3.3: {} | |
| 1164 | + | |
| 1165 | + tslib@2.8.1: {} | |
| 1166 | + | |
| 1167 | + typescript@5.9.3: {} | |
| 1168 | + | |
| 1169 | + undici-types@7.18.2: {} | |
modified
src/aiatlas/registry/__init__.py
+3 −1
@@ -90,7 +90,9 @@ def provider_ref(key: str): # type: ignore[no-untyped-def] | ||
| 90 | 90 | if p.get("openrouter_slug"): |
| 91 | 91 | ids["openrouter_provider"] = p["openrouter_slug"] |
| 92 | 92 | org = org_ref(p["organization"]) if p.get("organization") in organizations() else None |
| 93 | − return EntityRef(entity_type="provider", name=p["name"], identifiers=ids, aliases=list(p.get("aliases", [])), slug_hint=key, organization=org) | |
| 93 | + # providers share names with their companies ("Anthropic" the company vs "Anthropic API" the provider): keep slugs distinct and readable | |
| 94 | + slug = key if key not in organizations() else f"{key}-api" | |
| 95 | + return EntityRef(entity_type="provider", name=p["name"], identifiers=ids, aliases=list(p.get("aliases", [])), slug_hint=slug, organization=org) | |
| 94 | 96 | |
| 95 | 97 | |
| 96 | 98 | def provider_by_openrouter(slug: str) -> str | None: |
| 97 | 99 | |