Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions freebuff/web/src/app/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { CopyButton } from '@/components/copy-button'
import { HeroGrid } from '@/components/hero-grid'
import { Icons } from '@/components/icons'
import { cn } from '@/lib/utils'
import { CompactLiveStats } from './live/live-client'
import { HomepageLiveStats } from './live/live-summary'

const INSTALL_COMMAND = 'npm install -g freebuff'

Expand Down Expand Up @@ -569,7 +569,7 @@ export default function HomeClient() {
</div>
</div>

<CompactLiveStats />
<HomepageLiveStats />
</div>
)
}
55 changes: 5 additions & 50 deletions freebuff/web/src/app/live/live-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,18 @@ import { useEffect, useState } from 'react'
import { CopyButton } from '@/components/copy-button'
import { cn } from '@/lib/utils'

import {
EMPTY_LIVE_STATS,
countryName,
useLiveStats,
} from './live-stats-client'
import { COUNTRY_POINTS, WORLD_LAND_PATHS } from './world-map-data'

import type { FreebuffLiveStats } from '@/server/live-stats'
import type { LucideIcon } from 'lucide-react'

const INSTALL_COMMAND = 'npm install -g freebuff'
const POLL_MS = 60_000
const MAP_SIZE = { width: 1000, height: 520 }
const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' })
const EMPTY_LIVE_STATS: FreebuffLiveStats = {
totalLiveUsers: 0,
countries: [],
models: [],
generatedAt: '1970-01-01T00:00:00.000Z',
}
type CountryPoint = readonly [lat: number, lon: number]
type PlottedCountry = FreebuffLiveStats['countries'][number] & {
point: CountryPoint
Expand All @@ -47,14 +44,6 @@ const SETUP_STEPS = [
'freebuff',
]

function countryName(code: string): string {
if (code === 'UNKNOWN') {
return 'Unknown'
}

return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code
}

function formattedTime(iso: string): string {
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
Expand Down Expand Up @@ -113,40 +102,6 @@ function isPlottedCountry(
return country !== null
}

function useLiveStats(
initialStats: FreebuffLiveStats,
options: { refreshOnMount?: boolean } = {},
) {
const [stats, setStats] = useState(initialStats)

useEffect(() => {
let isMounted = true

async function refresh() {
try {
const response = await fetch('/api/live', { cache: 'no-store' })
if (response.ok && isMounted) {
setStats((await response.json()) as FreebuffLiveStats)
}
} catch {
// Keep the previous snapshot if a transient refresh fails.
}
}

if (options.refreshOnMount) {
void refresh()
}

const interval = window.setInterval(refresh, POLL_MS)
return () => {
isMounted = false
window.clearInterval(interval)
}
}, [options.refreshOnMount])

return stats
}

function LiveUsersHero({ value }: { value: number }) {
return (
<div className="relative overflow-hidden rounded-lg border border-acid-matrix/35 bg-[radial-gradient(circle_at_20%_20%,rgba(124,255,63,0.22),transparent_34%),linear-gradient(135deg,rgba(124,255,63,0.12),rgba(34,211,238,0.06)_48%,rgba(255,255,255,0.04))] p-5 shadow-[0_0_55px_rgba(124,255,63,0.16),inset_0_1px_0_rgba(255,255,255,0.12)] md:min-w-[310px] md:p-6">
Expand Down
87 changes: 87 additions & 0 deletions freebuff/web/src/app/live/live-stats-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client'

import { useEffect, useState } from 'react'

import type { FreebuffLiveStats } from '@/server/live-stats'

const POLL_MS = 60_000
const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' })

export const EMPTY_LIVE_STATS: FreebuffLiveStats = {
totalLiveUsers: 0,
countries: [],
models: [],
generatedAt: '1970-01-01T00:00:00.000Z',
}

export function countryName(code: string): string {
if (code === 'UNKNOWN') {
return 'Unknown'
}

return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code
}

export function useLiveStats(
initialStats: FreebuffLiveStats,
options: {
enabled?: boolean
pauseWhenHidden?: boolean
refreshOnMount?: boolean
} = {},
) {
const {
enabled = true,
pauseWhenHidden = false,
refreshOnMount = false,
} = options
const [stats, setStats] = useState(initialStats)

useEffect(() => {
if (!enabled) {
return
}

let isMounted = true

async function refresh() {
if (pauseWhenHidden && document.visibilityState === 'hidden') {
return
}

try {
const response = await fetch('/api/live', { cache: 'no-store' })
if (response.ok && isMounted) {
setStats((await response.json()) as FreebuffLiveStats)
}
} catch {
// Keep the previous snapshot if a transient refresh fails.
}
}

if (refreshOnMount) {
void refresh()
}

const interval = window.setInterval(refresh, POLL_MS)
const refreshWhenVisible = () => {
if (document.visibilityState === 'visible') {
void refresh()
}
}

if (pauseWhenHidden) {
document.addEventListener('visibilitychange', refreshWhenVisible)
}

return () => {
isMounted = false
window.clearInterval(interval)
if (pauseWhenHidden) {
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}
}, [enabled, pauseWhenHidden, refreshOnMount])

return stats
}
166 changes: 166 additions & 0 deletions freebuff/web/src/app/live/live-summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
'use client'

import { ArrowRight, Cpu, Globe2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'

import {
EMPTY_LIVE_STATS,
countryName,
useLiveStats,
} from './live-stats-client'

import type { FreebuffLiveStats } from '@/server/live-stats'
import type { LucideIcon } from 'lucide-react'

function useHomepageLiveStats(initialStats: FreebuffLiveStats) {
const [isVisible, setIsVisible] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
const stats = useLiveStats(initialStats, {
enabled: isVisible,
pauseWhenHidden: true,
refreshOnMount: true,
})

useEffect(() => {
const section = sectionRef.current
if (!section || !('IntersectionObserver' in window)) {
setIsVisible(true)
return
}

const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ rootMargin: '240px 0px', threshold: 0.01 },
)

observer.observe(section)
return () => observer.disconnect()
}, [])

return { sectionRef, stats }
}

function LiveRows({
title,
icon: Icon,
rows,
emptyLabel,
}: {
title: string
icon: LucideIcon
rows: { label: string; value: number; sublabel?: string }[]
emptyLabel: string
}) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="font-mono text-xs uppercase tracking-[0.18em] text-white/46">
{title}
</h3>
<Icon className="h-4 w-4 text-cyan-300" aria-hidden />
</div>
{rows.length > 0 ? (
<div className="space-y-2">
{rows.map((row) => (
<div
key={`${row.label}-${row.sublabel ?? ''}`}
className="flex items-center justify-between gap-3 rounded-md bg-black/25 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white/86">
{row.label}
</div>
{row.sublabel && (
<div className="font-mono text-[11px] text-white/36">
{row.sublabel}
</div>
)}
</div>
<div className="font-mono text-base text-acid-matrix">
{row.value.toLocaleString()}
</div>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-white/12 bg-black/20 px-3 py-5 text-center text-sm text-white/45">
{emptyLabel}
</div>
)}
</div>
)
}

export function HomepageLiveStats({
initialStats = EMPTY_LIVE_STATS,
}: {
initialStats?: FreebuffLiveStats
}) {
const { sectionRef, stats } = useHomepageLiveStats(initialStats)
const isLoading = stats.generatedAt === EMPTY_LIVE_STATS.generatedAt
const topCountries = stats.countries.slice(0, 4).map((country) => ({
label: countryName(country.countryCode),
sublabel: country.countryCode,
value: country.count,
}))
const topModels = stats.models.slice(0, 4).map((model) => ({
label: model.displayName,
value: model.count,
}))
const countryEmptyLabel = isLoading
? 'Loading active countries...'
: 'No active countries yet.'
const modelEmptyLabel = isLoading
? 'Loading active models...'
: 'No active models right now.'

return (
<section
ref={sectionRef}
className="relative overflow-hidden bg-black py-14 md:py-20"
>
<div className="absolute inset-0 bg-[linear-gradient(rgba(124,255,63,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.035)_1px,transparent_1px)] bg-[size:56px_56px]" />
<div className="relative container mx-auto px-4">
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-end">
<div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-acid-matrix shadow-[0_0_20px_rgba(124,255,63,0.9)]" />
<span className="font-mono text-xs uppercase tracking-[0.22em] text-white/48">
Active users
</span>
</div>
<div className="mt-3 font-mono text-6xl font-medium leading-none text-acid-matrix neon-text md:text-8xl">
{isLoading ? '...' : stats.totalLiveUsers.toLocaleString()}
</div>
<p className="mt-4 max-w-md text-sm leading-6 text-white/52 md:text-base">
Active Freebuff sessions right now, grouped by country and model.
</p>
<Link
href="/live"
className="mt-6 inline-flex items-center gap-2 rounded-md border border-acid-matrix/45 bg-acid-matrix/10 px-4 py-2 text-sm font-medium text-acid-matrix transition-colors hover:bg-acid-matrix/15"
>
<span>View live map</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</div>

<div className="grid gap-4 md:grid-cols-2">
<LiveRows
title="Top countries"
icon={Globe2}
rows={topCountries}
emptyLabel={countryEmptyLabel}
/>
<LiveRows
title="Models"
icon={Cpu}
rows={topModels}
emptyLabel={modelEmptyLabel}
/>
</div>
</div>
</div>
</section>
)
}
Loading