feat: enhance worlds page with live stats, type distribution bars, and recent worlds list
This commit is contained in:
@@ -1,82 +1,229 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
async function getWorldStats() {
|
||||
try {
|
||||
const res = await fetch("https://worlds.blackroad.io/stats", { next: { revalidate: 30 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
interface WorldStats {
|
||||
total: number
|
||||
by_node: Record<string, number>
|
||||
by_type: Record<string, number>
|
||||
recent: WorldEntry[]
|
||||
}
|
||||
|
||||
async function getRecentWorlds() {
|
||||
interface WorldEntry {
|
||||
name: string
|
||||
type: 'world' | 'lore' | 'code' | string
|
||||
node: string
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
world: '🌍',
|
||||
lore: '📖',
|
||||
code: '💻',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
world: 'text-green-400',
|
||||
lore: 'text-purple-400',
|
||||
code: 'text-blue-400',
|
||||
}
|
||||
|
||||
async function getWorldStats(): Promise<{ worlds: WorldStats } | null> {
|
||||
try {
|
||||
const res = await fetch("https://worlds.blackroad.io/recent?limit=20", { next: { revalidate: 30 } })
|
||||
return res.ok ? res.json() : []
|
||||
} catch { return [] }
|
||||
const res = await fetch('https://worlds.blackroad.io/stats', {
|
||||
next: { revalidate: 30 },
|
||||
})
|
||||
return res.ok ? res.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return `${Math.floor(diffHours / 24)}d ago`
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
value: number
|
||||
max: number
|
||||
color: string
|
||||
}) {
|
||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
||||
const filled = Math.round(pct / 5) // 20 chars max
|
||||
const empty = 20 - filled
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-mono text-sm">
|
||||
<span className={`${color} select-none tracking-tighter`}>
|
||||
{'█'.repeat(filled)}
|
||||
{'░'.repeat(empty)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function WorldsPage() {
|
||||
const [stats, worlds] = await Promise.all([getWorldStats(), getRecentWorlds()])
|
||||
const data = await getWorldStats()
|
||||
const worlds = data?.worlds ?? null
|
||||
const total = worlds?.total ?? 0
|
||||
const byNode = worlds?.by_node ?? {}
|
||||
const byType = worlds?.by_type ?? {}
|
||||
const recent = worlds?.recent ?? []
|
||||
const maxType = Math.max(...Object.values(byType).map(Number), 1)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-6 space-y-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">🌍 Worlds</h1>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
🌍 Worlds
|
||||
<span className="text-xs font-normal text-gray-500 bg-gray-800 px-2 py-1 rounded-full ml-1 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||
Live
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">AI-generated worlds running on BlackRoad Pi fleet</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-mono font-bold text-amber-400">{stats?.total ?? "—"}</div>
|
||||
<div className="text-sm text-gray-400">total worlds</div>
|
||||
<div className="text-6xl font-mono font-black text-amber-400 leading-none tabular-nums">
|
||||
{total || '—'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">worlds generated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Stats */}
|
||||
{/* Node breakdown */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Object.entries(stats?.by_node ?? {}).map(([node, count]) => (
|
||||
<div key={node} className="bg-gray-900 border border-gray-700 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm font-mono">{node}</div>
|
||||
<div className="text-2xl font-bold text-white mt-1">{count as number}</div>
|
||||
<div className="text-xs text-gray-500">worlds</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.entries(byNode).length > 0 ? (
|
||||
Object.entries(byNode).map(([node, count]) => (
|
||||
<div
|
||||
key={node}
|
||||
className="bg-gray-900 border border-gray-700 hover:border-amber-700/60 transition-colors rounded-xl p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse flex-shrink-0" />
|
||||
<span className="text-gray-300 text-sm font-mono truncate">{node}</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black font-mono text-white tabular-nums">
|
||||
{count as number}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">worlds on this node</div>
|
||||
<div className="mt-3 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 rounded-full transition-all duration-700"
|
||||
style={{
|
||||
width: `${total > 0 ? Math.round(((count as number) / total) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1 font-mono">
|
||||
{total > 0 ? Math.round(((count as number) / total) * 100) : 0}% of total
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
['aria64', 'alice'].map((node) => (
|
||||
<div
|
||||
key={node}
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-5 animate-pulse"
|
||||
>
|
||||
<div className="h-3 bg-gray-700 rounded w-16 mb-4" />
|
||||
<div className="h-10 bg-gray-700 rounded w-14 mb-2" />
|
||||
<div className="h-2 bg-gray-800 rounded-full mt-4" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type breakdown */}
|
||||
{stats?.by_type && (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">By Type</h2>
|
||||
<div className="flex gap-6">
|
||||
{Object.entries(stats.by_type).map(([type, count]) => (
|
||||
<div key={type} className="text-center">
|
||||
<div className="text-xl font-bold text-white">{count as number}</div>
|
||||
<div className="text-xs text-gray-500 capitalize">{type}</div>
|
||||
{/* Type distribution */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5">
|
||||
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Type Distribution
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(byType).length > 0 ? (
|
||||
Object.entries(byType).map(([type, count]) => (
|
||||
<div key={type} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 font-medium text-white">
|
||||
<span className="text-base leading-none">{TYPE_ICONS[type] ?? '🔷'}</span>
|
||||
<span className="capitalize">{type}</span>
|
||||
</span>
|
||||
<span className="font-mono text-gray-300 tabular-nums">{count as number}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={count as number}
|
||||
max={maxType}
|
||||
color={TYPE_COLORS[type] ?? 'text-gray-400'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 text-sm text-gray-500 py-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse flex-shrink-0" />
|
||||
Generating...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent worlds */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3 uppercase tracking-wider">Recent Worlds</h2>
|
||||
{Array.isArray(worlds) && worlds.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{worlds.slice(0, 10).map((w: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 border-b border-gray-800 last:border-0">
|
||||
<div>
|
||||
<span className="text-white font-medium">{w.title ?? w.name ?? `World #${i+1}`}</span>
|
||||
{w.type && <span className="ml-2 text-xs text-amber-400 bg-amber-400/10 px-2 py-0.5 rounded">{w.type}</span>}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5">
|
||||
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
|
||||
Recent Worlds
|
||||
</h2>
|
||||
{recent.length > 0 ? (
|
||||
<div>
|
||||
{recent.slice(0, 10).map((w: WorldEntry, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between py-2.5 border-b border-gray-800/80 last:border-0 group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-xl leading-none flex-shrink-0">
|
||||
{TYPE_ICONS[w.type] ?? '🔷'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white font-medium text-sm group-hover:text-amber-400 transition-colors truncate">
|
||||
{w.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
className={`text-xs font-mono capitalize ${TYPE_COLORS[w.type] ?? 'text-gray-400'}`}
|
||||
>
|
||||
{w.type}
|
||||
</span>
|
||||
<span className="text-gray-700">·</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{w.node}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 font-mono flex-shrink-0 ml-3">
|
||||
{formatRelativeTime(w.generated_at)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{w.node ?? ""}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Worlds are generating... check back soon.</p>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 py-4">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse inline-block flex-shrink-0" />
|
||||
Generating worlds... check back soon.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 text-center">Auto-refreshes every 30s • Powered by aria64 + alice Pi nodes</p>
|
||||
<p className="text-xs text-gray-600 text-center">
|
||||
Auto-refreshes every 30s · Powered by aria64 + alice Pi nodes
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user