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
16 changes: 14 additions & 2 deletions apps/comps/app/arenas/[arenaId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,28 @@ import { Metadata } from "next";

import { ArenaDetailPage } from "@/components/arena-detail/arena-detail-page";
import { createMetadata } from "@/lib/metadata";
import { createSafeClient } from "@/rpc/clients/server-side";

export async function generateMetadata({
params,
}: {
params: Promise<{ arenaId: string }>;
}): Promise<Metadata> {
const { arenaId } = await params;
try {
const client = await createSafeClient();
const { data: arena } = await client.arena.getById({ id: arenaId });
if (arena) {
const title = `Recall | ${arena.name}`;
const description = `Leaderboard and competitions for ${arena.name}.`;
return createMetadata(title, description);
}
} catch (error) {
console.error("Failed to fetch arena for metadata:", error);
}
return createMetadata(
`Arena: ${arenaId}`,
`Arena leaderboard for ${arenaId}`,
"Recall | AI Arenas",
"Specialized environments for different competition formats and skills",
);
}

Expand Down
18 changes: 2 additions & 16 deletions apps/comps/app/arenas/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React from "react";
import { Card } from "@recallnet/ui2/components/card";

import { ArenaCard } from "@/components/arena-card";
import { ArenasHubSkeleton } from "@/components/arenas/hub/skeleton";
import { tanstackClient } from "@/rpc/clients/tanstack-query";

export default function ArenasPageClient() {
Expand All @@ -20,22 +21,7 @@ export default function ArenasPageClient() {
);

if (isLoading) {
return (
<div className="container mx-auto max-w-7xl px-4 py-16">
<div className="mb-12 text-center">
<h1 className="mb-4 text-5xl font-bold text-white">Arenas</h1>
<p className="text-lg text-gray-400">Loading arenas...</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="bg-card h-80 animate-pulse rounded-sm"
></div>
))}
</div>
</div>
);
return <ArenasHubSkeleton />;
}

if (error) {
Expand Down
4 changes: 2 additions & 2 deletions apps/comps/app/arenas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import ArenasPageClient from "./client";

export async function generateMetadata(): Promise<Metadata> {
return createMetadata(
"Arenas",
"Explore specialized environments for different competition formats and skills",
"Recall | AI Arenas",
"Specialized environments for different competition formats and skills",
);
}

Expand Down
46 changes: 35 additions & 11 deletions apps/comps/components/arena-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React from "react";

import { Badge } from "@recallnet/ui2/components/badge";
import { Card } from "@recallnet/ui2/components/card";
import { Skeleton } from "@recallnet/ui2/components/skeleton";
import { cn } from "@recallnet/ui2/lib/utils";

import { AgentAvatar } from "@/components/agent-avatar";
Expand All @@ -28,7 +29,7 @@ export const ArenaCard: React.FC<ArenaCardProps> = ({ arena, className }) => {
: undefined;

// Fetch top 3 agents for this arena
const { data: leaderboard } = useQuery(
const { data: leaderboard, isLoading: isLoadingLeaderboard } = useQuery(
tanstackClient.leaderboard.getGlobal.queryOptions({
input: {
arenaId: arena.id,
Expand All @@ -39,14 +40,15 @@ export const ArenaCard: React.FC<ArenaCardProps> = ({ arena, className }) => {
);

// Fetch competitions for this specific arena (efficient - only fetches 4)
const { data: arenaCompetitionsData } = useQuery(
tanstackClient.arena.getCompetitions.queryOptions({
input: {
arenaId: arena.id,
paging: { limit: 4, offset: 0, sort: "-startDate" },
},
}),
);
const { data: arenaCompetitionsData, isLoading: isLoadingCompetitions } =
useQuery(
tanstackClient.arena.getCompetitions.queryOptions({
input: {
arenaId: arena.id,
paging: { limit: 4, offset: 0, sort: "-startDate" },
},
}),
);

const topAgents = leaderboard?.agents || [];
const topScore = topAgents[0]?.score || 0;
Expand Down Expand Up @@ -110,7 +112,13 @@ export const ArenaCard: React.FC<ArenaCardProps> = ({ arena, className }) => {

{/* Latest Competitions - Single row, horizontal scroll */}
<div className="shrink-0 overflow-hidden px-4 py-2 md:px-6">
{arenaCompetitions.length > 0 ? (
{isLoadingCompetitions ? (
<div className="no-scrollbar flex gap-2 overflow-x-auto pb-1">
{Array.from({ length: 3 }).map((_, idx) => (
<Skeleton key={idx} className="h-9 w-28 rounded" />
))}
</div>
) : arenaCompetitions.length > 0 ? (
<div className="no-scrollbar flex gap-2 overflow-x-auto pb-1">
{arenaCompetitions.map((comp) => {
const dateLabel =
Expand Down Expand Up @@ -174,7 +182,23 @@ export const ArenaCard: React.FC<ArenaCardProps> = ({ arena, className }) => {
{/* Top 3 Agents - Flexible height but same starting point */}
<div className="min-h-[200px] flex-1 border-t border-gray-800 bg-gray-900/30 p-3 md:p-4">
<div className="space-y-2 md:space-y-3">
{topAgents.length > 0 ? (
{isLoadingLeaderboard ? (
Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded p-1.5 md:gap-3 md:p-2"
>
<div className="flex h-5 w-5 items-center justify-center rounded bg-gray-700" />
<div className="size-4 rounded-full bg-gray-700" />
<div className="min-w-0 flex-1">
<Skeleton className="h-3 w-28 rounded-xl" />
<Skeleton className="mt-1 h-3 w-16 rounded-xl" />
</div>
<div className="h-2 w-16 rounded-full bg-gray-800 md:w-20" />
<Skeleton className="h-3 w-10 rounded-xl" />
</div>
))
) : topAgents.length > 0 ? (
topAgents.map((agent, index) => {
const barWidth = topScore ? (agent.score / topScore) * 100 : 0;
const barColor = getAgentColor(agent.name);
Expand Down
10 changes: 2 additions & 8 deletions apps/comps/components/arena-detail/arena-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { mergeCompetitionsWithUserData } from "@/utils/competition-utils";

import { ArenaDetailLeaderboardTable } from "./arena-detail-leaderboard-table";
import { ArenaDetailLeaderboardTableMobile } from "./arena-detail-leaderboard-table-mobile";
import { ArenaDetailSkeleton } from "./skeleton";

interface ArenaDetailPageProps {
arenaId: string;
Expand Down Expand Up @@ -123,14 +124,7 @@ export const ArenaDetailPage: React.FC<ArenaDetailPageProps> = ({
const error = arenaError || leaderboardError;

if (isLoading) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<div className="flex items-center gap-3">
<Loader2 size={24} className="animate-spin text-gray-400" />
<span className="text-gray-400">Loading arena data...</span>
</div>
</div>
);
return <ArenaDetailSkeleton />;
}

if (error) {
Expand Down
98 changes: 98 additions & 0 deletions apps/comps/components/arena-detail/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import React from "react";

import { Card } from "@recallnet/ui2/components/card";
import { Skeleton } from "@recallnet/ui2/components/skeleton";

/**
* Loading skeleton for the Arena detail page.
* Renders placeholders for title, metadata badges, stats, leaderboard table and competitions.
*/
export const ArenaDetailSkeleton: React.FC = () => {
return (
<div className="space-y-8 pb-16">
{/* Breadcrumb placeholder */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-16 rounded" />
<span className="text-gray-600">/</span>
<Skeleton className="h-4 w-20 rounded" />
<span className="text-gray-600">/</span>
<Skeleton className="h-4 w-28 rounded" />
</div>

{/* Title */}
<div className="space-y-2">
<Skeleton className="h-10 w-72 rounded-xl" />
</div>

{/* Arena Metadata */}
<Card className="border-gray-800 bg-gray-900/30 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-10 rounded" />
<Skeleton className="h-6 w-28 rounded-xl" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-12 rounded" />
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-6 w-20 rounded-xl" />
))}
</div>
</div>
</Card>

{/* Stats - Desktop */}
<div className="hidden grid-cols-3 gap-6 md:grid">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="p-6 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<Skeleton className="h-6 w-10 rounded" />
<Skeleton className="h-8 w-16 rounded-xl" />
</div>
<Skeleton className="mx-auto h-4 w-24 rounded" />
</Card>
))}
</div>

{/* Leaderboard table placeholder */}
<Card className="p-4">
<div className="space-y-3">
{/* Table header */}
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full rounded" />
))}
</div>
{/* Rows */}
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="grid grid-cols-4 items-center gap-4">
{Array.from({ length: 4 }).map((__, j) => (
<Skeleton key={j} className="h-4 w-full rounded" />
))}
</div>
))}
</div>
</Card>

{/* Competitions skeleton */}
<div className="space-y-4">
<Skeleton className="h-7 w-40 rounded" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-5">
<div className="space-y-3">
<Skeleton className="h-6 w-3/4 rounded" />
<Skeleton className="h-4 w-1/2 rounded" />
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20 rounded-xl" />
<Skeleton className="h-6 w-24 rounded-xl" />
</div>
</div>
</Card>
))}
</div>
</div>
</div>
);
};
83 changes: 83 additions & 0 deletions apps/comps/components/arenas/hub/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import React from "react";

import { Card } from "@recallnet/ui2/components/card";
import { Skeleton } from "@recallnet/ui2/components/skeleton";

/**
* Skeleton for individual arena cards
* Matches the exact structure and heights of ArenaCard
*/
export const ArenaCardSkeleton: React.FC = () => {
return (
<Card
cropSize={35}
corner="bottom-right"
className="bg-card flex h-full w-full flex-col"
>
{/* Header Section */}
<div className="h-18 md:h-18 flex shrink-0 items-center justify-between p-4 md:p-6">
<Skeleton className="h-5 w-40 rounded-xl" />
<Skeleton className="h-5 w-16 rounded-xl" />
</div>

{/* Skill Badge */}
<div className="h-10 shrink-0 px-4 md:px-6">
<Skeleton className="h-6 w-32 rounded" />
</div>

{/* Competitions */}
<div className="shrink-0 overflow-hidden px-4 py-2 md:px-6">
<div className="no-scrollbar flex gap-2 pb-1">
{Array.from({ length: 3 }).map((_, idx) => (
<Skeleton key={idx} className="h-9 w-28 flex-shrink-0 rounded" />
))}
</div>
</div>

{/* Top 3 Agents */}
<div className="min-h-[200px] flex-1 border-t border-gray-800 bg-gray-900/30 p-3 md:p-4">
<div className="space-y-2 md:space-y-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded p-1.5 md:gap-3 md:p-2"
>
<div className="flex h-5 w-5 items-center justify-center rounded bg-gray-700" />
<div className="size-4 rounded-full bg-gray-700" />
<div className="min-w-0 flex-1">
<Skeleton className="h-3 w-28 rounded-xl" />
<Skeleton className="mt-1 h-3 w-16 rounded-xl" />
</div>
<div className="h-2 w-16 rounded-full bg-gray-800 md:w-20" />
<Skeleton className="h-3 w-10 rounded-xl" />
</div>
))}
</div>
</div>
</Card>
);
};

/**
* Skeleton for the Arenas hub page
*/
export const ArenasHubSkeleton: React.FC = () => {
return (
<div className="mt-10 space-y-8 pb-16">
{/* Header */}
<div className="space-y-4 text-center">
<Skeleton className="mx-auto h-10 w-56 rounded-xl" />
<Skeleton className="mx-auto h-5 w-[44ch] max-w-full rounded-xl" />
</div>

{/* Arenas Grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<ArenaCardSkeleton key={idx} />
))}
</div>
</div>
);
};