diff --git a/freebuff/web/src/app/api/live/route.ts b/freebuff/web/src/app/api/live/route.ts new file mode 100644 index 0000000000..dd39d7c632 --- /dev/null +++ b/freebuff/web/src/app/api/live/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' + +import { getFreebuffLiveStats } from '@/server/live-stats' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function GET() { + const stats = await getFreebuffLiveStats() + return NextResponse.json(stats, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + }) +} diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx new file mode 100644 index 0000000000..4396830b41 --- /dev/null +++ b/freebuff/web/src/app/live/live-client.tsx @@ -0,0 +1,474 @@ +'use client' + +import { motion } from 'framer-motion' +import { ChevronDown, Cpu, Globe2 } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/copy-button' + +import type { FreebuffLiveStats } from '@/server/live-stats' +import type { LucideIcon } from 'lucide-react' + +const INSTALL_COMMAND = 'npm install -g freebuff' +const POLL_MS = 15_000 +const MAP_SIZE = { width: 1000, height: 520 } +const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }) + +const COUNTRY_POINTS: Record = { + AT: [47.5, 14.5], + AU: [-25.3, 133.8], + BE: [50.5, 4.5], + CA: [56.1, -106.3], + CH: [46.8, 8.2], + DE: [51.2, 10.4], + DK: [56, 10], + ES: [40.4, -3.7], + FI: [64, 26], + FR: [46.2, 2.2], + GB: [55, -3], + IE: [53.4, -8.2], + IL: [31, 35], + IS: [65, -18], + IT: [42.8, 12.8], + LI: [47.1, 9.6], + LU: [49.8, 6.1], + MT: [35.9, 14.4], + NL: [52.1, 5.3], + NO: [61, 8], + NZ: [-41, 174], + PT: [39.4, -8.2], + SE: [62, 15], + SG: [1.4, 103.8], + US: [39.8, -98.6], +} + +const LAND_PATHS = [ + 'M93 151 C137 94 226 78 303 114 C376 149 362 217 288 237 C229 254 229 323 171 303 C104 280 61 197 93 151Z', + 'M276 291 C320 311 350 354 330 414 C313 468 269 500 247 466 C223 428 232 365 205 332 C185 307 229 277 276 291Z', + 'M444 118 C523 79 655 87 727 124 C799 160 890 160 923 214 C955 265 879 295 823 270 C744 235 725 292 638 283 C551 274 502 240 438 259 C386 274 338 225 357 176 C371 142 403 138 444 118Z', + 'M690 310 C731 277 796 297 825 333 C852 366 831 426 779 436 C728 447 671 390 690 310Z', + 'M766 439 C805 423 863 442 889 478 C837 492 792 489 746 470 C748 455 755 446 766 439Z', + 'M421 96 C448 80 495 83 516 105 C486 118 454 121 421 96Z', +] + +const SETUP_STEPS = [ + 'Open your terminal', + 'Navigate to your project', + INSTALL_COMMAND, + 'freebuff', +] + +function countryName(code: string): string { + return code === 'UNKNOWN' ? 'Unknown' : (REGION_NAMES.of(code) ?? code) +} + +function formattedTime(iso: string): string { + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }).format(new Date(iso)) +} + +function projectPoint(lat: number, lon: number) { + return { + x: ((lon + 180) / 360) * MAP_SIZE.width, + y: ((90 - lat) / 180) * MAP_SIZE.height, + } +} + +function useLiveStats(initialStats: FreebuffLiveStats) { + const [stats, setStats] = useState(initialStats) + + useEffect(() => { + let isMounted = true + + async function refresh() { + const response = await fetch('/api/live', { cache: 'no-store' }) + if (response.ok && isMounted) { + setStats((await response.json()) as FreebuffLiveStats) + } + } + + const interval = window.setInterval(refresh, POLL_MS) + return () => { + isMounted = false + window.clearInterval(interval) + } + }, []) + + return stats +} + +function StatTile({ label, value }: { label: string; value: string }) { + return ( +
+
+ + {label} + +
+
+ {value} +
+
+ ) +} + +function Panel({ + icon: Icon, + title, + children, +}: { + icon: LucideIcon + title: string + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ +
+ {children} +
+ ) +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function WorldMap({ stats }: { stats: FreebuffLiveStats }) { + const maxCount = Math.max(1, ...stats.countries.map((row) => row.count)) + const plottedCountries = stats.countries + .map((country) => { + const point = COUNTRY_POINTS[country.countryCode] + return point ? { ...country, point } : null + }) + .filter((country) => country !== null) + + return ( +
+ + + + + + + + + + + + + + + + + + {LAND_PATHS.map((path) => ( + + ))} + + {plottedCountries.map(({ countryCode, count, point }) => { + const [lat, lon] = point + const { x, y } = projectPoint(lat, lon) + const radius = 7 + Math.sqrt(count / maxCount) * 20 + + return ( + + + + + {count} + + + {countryName(countryCode)}: {count} + + + ) + })} + + + {plottedCountries.length === 0 && ( +
+
Standing by
+
+ Live sessions will appear here as users start Freebuff. +
+
+ )} +
+ ) +} + +function ModelBars({ stats }: { stats: FreebuffLiveStats }) { + const maxCount = Math.max(1, ...stats.models.map((model) => model.count)) + + if (stats.models.length === 0) { + return No models are active right now. + } + + return ( +
+ {stats.models.map((model) => ( +
+
+ {model.displayName} + {model.count} +
+
+ +
+
+ ))} +
+ ) +} + +function CountryList({ stats }: { stats: FreebuffLiveStats }) { + if (stats.countries.length === 0) { + return No active countries yet. + } + + return ( +
+ {stats.countries.map((country) => ( +
+
+
+ {countryName(country.countryCode)} +
+
+ {country.countryCode} +
+
+
+ {country.count} +
+
+ ))} +
+ ) +} + +function InstallCallout() { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+
+ + Freebuff +
+
+ freebuff +
+
The free coding agent
+
+ + +
+
+ $ + + {INSTALL_COMMAND} + + +
+ + + + {isOpen && ( +
    + {SETUP_STEPS.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+ )} +
+
+
+ ) +} + +export default function LiveClient({ + initialStats, +}: { + initialStats: FreebuffLiveStats +}) { + const [hasMounted, setHasMounted] = useState(false) + const stats = useLiveStats(initialStats) + const topCountry = useMemo( + () => + stats.countries[0] + ? countryName(stats.countries[0].countryCode) + : 'None yet', + [stats.countries], + ) + + useEffect(() => { + setHasMounted(true) + }, []) + + return ( +
+
+
+
+
+
+
+

+ + + + Freebuff live +

+ {hasMounted && ( + + Updated {formattedTime(stats.generatedAt)} + + )} +
+
+
+ +
+ + +
+
+
+ +
+
+ + +
+ + + + + + + +
+
+
+ + +
+ ) +} diff --git a/freebuff/web/src/app/live/page.tsx b/freebuff/web/src/app/live/page.tsx new file mode 100644 index 0000000000..8a548a3d18 --- /dev/null +++ b/freebuff/web/src/app/live/page.tsx @@ -0,0 +1,33 @@ +import { env } from '@codebuff/common/env' + +import { getFreebuffLiveStats } from '@/server/live-stats' + +import LiveClient from './live-client' + +import type { Metadata } from 'next' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function generateMetadata(): Promise { + const canonical = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/live` + return { + title: 'Live Freebuff Users', + description: 'Live aggregate Freebuff usage by country and model.', + alternates: { + canonical, + }, + openGraph: { + title: 'Live Freebuff Users', + description: 'Live aggregate Freebuff usage by country and model.', + url: canonical, + type: 'website', + siteName: 'Freebuff', + }, + } +} + +export default async function LivePage() { + const initialStats = await getFreebuffLiveStats() + return +} diff --git a/freebuff/web/src/components/footer.tsx b/freebuff/web/src/components/footer.tsx index 97cd24896e..858f00079a 100644 --- a/freebuff/web/src/components/footer.tsx +++ b/freebuff/web/src/components/footer.tsx @@ -1,7 +1,14 @@ +'use client' + import Image from 'next/image' import Link from 'next/link' +import { usePathname } from 'next/navigation' export function Footer() { + const pathname = usePathname() + + if (pathname === '/live') return null + return (