重构:将 "lobster" 重命名为 "claw" 并添加国际化支持 (i18n)

This commit is contained in:
richarjiang
2026-03-13 12:07:28 +08:00
parent fa4c458eda
commit 9e30771180
38 changed files with 1003 additions and 344 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("lobster") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE. OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("claw") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE.
## Commands ## Commands
@@ -34,16 +34,19 @@ bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
- **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`. - **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`.
- **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber. - **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber.
- **Validation**: Zod schemas in `lib/validators/schemas.ts`. - **Validation**: Zod schemas in `lib/validators/schemas.ts`.
- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `lobsters`, `heartbeats`, `tasks`, `geo_cache`. - **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `claws`, `heartbeats`, `tasks`, `geo_cache`.
- **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active lobster sorted sets, global/region stats, hourly activity, heatmap cache. - **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active claw sorted sets, global/region stats, hourly activity, heatmap cache.
- **i18n**: `next-intl` with locale-prefixed routing (`/en/...`, `/zh/...`). Config in `i18n/routing.ts`, middleware in `middleware.ts`, translations in `messages/en.json` and `messages/zh.json`.
### Frontend Structure ### Frontend Structure
- `app/[locale]/page.tsx` — Homepage (globe + dashboard)
- `app/[locale]/continent/[slug]/page.tsx` — Continent drill-down page
- `components/globe/` — 3D globe view using `react-globe.gl` (dynamically imported, no SSR) - `components/globe/` — 3D globe view using `react-globe.gl` (dynamically imported, no SSR)
- `components/map/` — 2D continent maps using `react-simple-maps` - `components/map/` — 2D continent maps using `react-simple-maps`
- `components/dashboard/` — Stats panel, region ranking, activity timeline, lobster feed - `components/dashboard/` — Stats panel, region ranking, activity timeline, claw feed
- `components/layout/` — Navbar, particle background, view switcher, install banner - `components/layout/` — Navbar, particle background, view switcher, install banner, language switcher
- `app/continent/[slug]/page.tsx` — Continent drill-down page - `messages/` — i18n translation files (en, zh)
## Environment Variables ## Environment Variables

View File

@@ -1,20 +1,15 @@
"use client"; "use client";
import { use } from "react"; import { use } from "react";
import { useTranslations } from "next-intl";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { ParticleBg } from "@/components/layout/particle-bg"; import { ParticleBg } from "@/components/layout/particle-bg";
import { ViewSwitcher } from "@/components/layout/view-switcher"; import { ViewSwitcher } from "@/components/layout/view-switcher";
import { ContinentMap } from "@/components/map/continent-map"; import { ContinentMap } from "@/components/map/continent-map";
import { StatsPanel } from "@/components/dashboard/stats-panel"; import { StatsPanel } from "@/components/dashboard/stats-panel";
import { LobsterFeed } from "@/components/dashboard/lobster-feed"; import { ClawFeed } from "@/components/dashboard/claw-feed";
const continentNames: Record<string, string> = { const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
asia: "Asia",
europe: "Europe",
americas: "Americas",
africa: "Africa",
oceania: "Oceania",
};
interface PageProps { interface PageProps {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -22,7 +17,12 @@ interface PageProps {
export default function ContinentPage({ params }: PageProps) { export default function ContinentPage({ params }: PageProps) {
const { slug } = use(params); const { slug } = use(params);
const name = continentNames[slug] ?? "Unknown"; const t = useTranslations("continents");
const tPage = useTranslations("continentPage");
const name = continentSlugs.includes(slug as (typeof continentSlugs)[number])
? t(slug as (typeof continentSlugs)[number])
: "Unknown";
return ( return (
<div className="relative min-h-screen"> <div className="relative min-h-screen">
@@ -38,7 +38,7 @@ export default function ContinentPage({ params }: PageProps) {
textShadow: "var(--glow-cyan)", textShadow: "var(--glow-cyan)",
}} }}
> >
{name} Region {tPage("regionTitle", { name })}
</h1> </h1>
<ViewSwitcher activeContinent={slug} /> <ViewSwitcher activeContinent={slug} />
</div> </div>
@@ -52,7 +52,7 @@ export default function ContinentPage({ params }: PageProps) {
{/* Side Panel */} {/* Side Panel */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<StatsPanel /> <StatsPanel />
<LobsterFeed /> <ClawFeed />
</div> </div>
</div> </div>
</main> </main>

73
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import "../globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("description"),
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `/${l}`])
),
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as "en" | "zh")) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale} className="dark">
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
}}
>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -6,7 +6,7 @@ import { ParticleBg } from "@/components/layout/particle-bg";
import { GlobeView } from "@/components/globe/globe-view"; import { GlobeView } from "@/components/globe/globe-view";
import { StatsPanel } from "@/components/dashboard/stats-panel"; import { StatsPanel } from "@/components/dashboard/stats-panel";
import { ActivityTimeline } from "@/components/dashboard/activity-timeline"; import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
import { LobsterFeed } from "@/components/dashboard/lobster-feed"; import { ClawFeed } from "@/components/dashboard/claw-feed";
import { RegionRanking } from "@/components/dashboard/region-ranking"; import { RegionRanking } from "@/components/dashboard/region-ranking";
export default function HomePage() { export default function HomePage() {
@@ -36,7 +36,7 @@ export default function HomePage() {
{/* Right Panel */} {/* Right Panel */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<LobsterFeed /> <ClawFeed />
</div> </div>
</div> </div>
</main> </main>

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { eq, desc, sql, and } from "drizzle-orm"; import { eq, desc, sql, and } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema"; import { claws, tasks } from "@/lib/db/schema";
import { getActiveLobsterIds } from "@/lib/redis"; import { getActiveClawIds } from "@/lib/redis";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
@@ -13,29 +13,29 @@ export async function GET(req: NextRequest) {
const conditions = []; const conditions = [];
if (region) { if (region) {
conditions.push(eq(lobsters.region, region)); conditions.push(eq(claws.region, region));
} }
const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const lobsterRows = await db const clawRows = await db
.select() .select()
.from(lobsters) .from(claws)
.where(whereClause) .where(whereClause)
.orderBy(desc(lobsters.lastHeartbeat)) .orderBy(desc(claws.lastHeartbeat))
.limit(limit); .limit(limit);
const totalResult = await db const totalResult = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(lobsters) .from(claws)
.where(whereClause); .where(whereClause);
const total = totalResult[0]?.count ?? 0; const total = totalResult[0]?.count ?? 0;
const activeLobsterIds = await getActiveLobsterIds(); const activeClawIds = await getActiveClawIds();
const activeSet = new Set(activeLobsterIds); const activeSet = new Set(activeClawIds);
const lobsterList = await Promise.all( const clawList = await Promise.all(
lobsterRows.map(async (lobster) => { clawRows.map(async (claw) => {
const latestTaskRows = await db const latestTaskRows = await db
.select({ .select({
summary: tasks.summary, summary: tasks.summary,
@@ -43,28 +43,28 @@ export async function GET(req: NextRequest) {
durationMs: tasks.durationMs, durationMs: tasks.durationMs,
}) })
.from(tasks) .from(tasks)
.where(eq(tasks.lobsterId, lobster.id)) .where(eq(tasks.clawId, claw.id))
.orderBy(desc(tasks.timestamp)) .orderBy(desc(tasks.timestamp))
.limit(1); .limit(1);
const lastTask = latestTaskRows[0] ?? null; const lastTask = latestTaskRows[0] ?? null;
return { return {
id: lobster.id, id: claw.id,
name: lobster.name, name: claw.name,
model: lobster.model, model: claw.model,
platform: lobster.platform, platform: claw.platform,
city: lobster.city, city: claw.city,
country: lobster.country, country: claw.country,
isOnline: activeSet.has(lobster.id), isOnline: activeSet.has(claw.id),
lastTask, lastTask,
}; };
}) })
); );
return NextResponse.json({ lobsters: lobsterList, total }); return NextResponse.json({ claws: clawList, total });
} catch (error) { } catch (error) {
console.error("Lobsters error:", error); console.error("Claws error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 }

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters, heartbeats } from "@/lib/db/schema"; import { claws, heartbeats } from "@/lib/db/schema";
import { import {
setLobsterOnline, setClawOnline,
updateActiveLobsters, updateActiveClaws,
incrementHourlyActivity, incrementHourlyActivity,
publishEvent, publishEvent,
} from "@/lib/redis"; } from "@/lib/redis";
@@ -31,8 +31,8 @@ export async function POST(req: NextRequest) {
} }
const apiKey = authHeader.slice(7); const apiKey = authHeader.slice(7);
const lobster = await validateApiKey(apiKey); const claw = await validateApiKey(apiKey);
if (!lobster) { if (!claw) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
} }
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
updatedAt: now, updatedAt: now,
}; };
if (clientIp !== lobster.ip) { if (clientIp !== claw.ip) {
const geo = await getGeoLocation(clientIp); const geo = await getGeoLocation(clientIp);
if (geo) { if (geo) {
updateFields.latitude = String(geo.latitude); updateFields.latitude = String(geo.latitude);
@@ -77,27 +77,27 @@ export async function POST(req: NextRequest) {
if (parsed.data.model) updateFields.model = parsed.data.model; if (parsed.data.model) updateFields.model = parsed.data.model;
if (parsed.data.platform) updateFields.platform = parsed.data.platform; if (parsed.data.platform) updateFields.platform = parsed.data.platform;
await setLobsterOnline(lobster.id, clientIp); await setClawOnline(claw.id, clientIp);
await updateActiveLobsters(lobster.id); await updateActiveClaws(claw.id);
await incrementHourlyActivity(); await incrementHourlyActivity();
await db await db
.update(lobsters) .update(claws)
.set(updateFields) .set(updateFields)
.where(eq(lobsters.id, lobster.id)); .where(eq(claws.id, claw.id));
// Insert heartbeat record asynchronously // Insert heartbeat record asynchronously
db.insert(heartbeats) db.insert(heartbeats)
.values({ lobsterId: lobster.id, ip: clientIp, timestamp: now }) .values({ clawId: claw.id, ip: clientIp, timestamp: now })
.then(() => {}) .then(() => {})
.catch((err: unknown) => console.error("Failed to insert heartbeat:", err)); .catch((err: unknown) => console.error("Failed to insert heartbeat:", err));
await publishEvent({ await publishEvent({
type: "heartbeat", type: "heartbeat",
lobsterId: lobster.id, clawId: claw.id,
lobsterName: (updateFields.name as string) ?? lobster.name, clawName: (updateFields.name as string) ?? claw.name,
city: (updateFields.city as string) ?? lobster.city, city: (updateFields.city as string) ?? claw.city,
country: (updateFields.country as string) ?? lobster.country, country: (updateFields.country as string) ?? claw.country,
}); });
return NextResponse.json({ ok: true, nextIn: 180 }); return NextResponse.json({ ok: true, nextIn: 180 });

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { gte, and, isNotNull, sql } from "drizzle-orm"; import { gte, and, isNotNull, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema"; import { claws } from "@/lib/db/schema";
import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis"; import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis";
export async function GET() { export async function GET() {
@@ -13,34 +13,34 @@ export async function GET() {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const activeLobsters = await db const activeClaws = await db
.select({ .select({
city: lobsters.city, city: claws.city,
country: lobsters.country, country: claws.country,
latitude: lobsters.latitude, latitude: claws.latitude,
longitude: lobsters.longitude, longitude: claws.longitude,
count: sql<number>`count(*)`, count: sql<number>`count(*)`,
}) })
.from(lobsters) .from(claws)
.where( .where(
and( and(
gte(lobsters.lastHeartbeat, fiveMinutesAgo), gte(claws.lastHeartbeat, fiveMinutesAgo),
isNotNull(lobsters.latitude), isNotNull(claws.latitude),
isNotNull(lobsters.longitude) isNotNull(claws.longitude)
) )
) )
.groupBy( .groupBy(
lobsters.city, claws.city,
lobsters.country, claws.country,
lobsters.latitude, claws.latitude,
lobsters.longitude claws.longitude
); );
const points = activeLobsters.map((row) => ({ const points = activeClaws.map((row) => ({
lat: Number(row.latitude), lat: Number(row.latitude),
lng: Number(row.longitude), lng: Number(row.longitude),
weight: row.count, weight: row.count,
lobsterCount: row.count, clawCount: row.count,
city: row.city, city: row.city,
country: row.country, country: row.country,
})); }));

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema"; import { claws } from "@/lib/db/schema";
import { import {
setLobsterOnline, setClawOnline,
updateActiveLobsters, updateActiveClaws,
incrementGlobalStat, incrementGlobalStat,
incrementRegionCount, incrementRegionCount,
publishEvent, publishEvent,
@@ -33,14 +33,14 @@ export async function POST(req: NextRequest) {
} }
const { name, model, platform } = parsed.data; const { name, model, platform } = parsed.data;
const lobsterId = nanoid(21); const clawId = nanoid(21);
const apiKey = generateApiKey(); const apiKey = generateApiKey();
const clientIp = getClientIp(req); const clientIp = getClientIp(req);
const geo = await getGeoLocation(clientIp); const geo = await getGeoLocation(clientIp);
const now = new Date(); const now = new Date();
await db.insert(lobsters).values({ await db.insert(claws).values({
id: lobsterId, id: clawId,
apiKey, apiKey,
name, name,
model: model ?? null, model: model ?? null,
@@ -58,9 +58,9 @@ export async function POST(req: NextRequest) {
updatedAt: now, updatedAt: now,
}); });
await setLobsterOnline(lobsterId, clientIp); await setClawOnline(clawId, clientIp);
await updateActiveLobsters(lobsterId); await updateActiveClaws(clawId);
await incrementGlobalStat("total_lobsters"); await incrementGlobalStat("total_claws");
if (geo?.region) { if (geo?.region) {
await incrementRegionCount(geo.region); await incrementRegionCount(geo.region);
@@ -68,14 +68,14 @@ export async function POST(req: NextRequest) {
await publishEvent({ await publishEvent({
type: "online", type: "online",
lobsterId, clawId,
lobsterName: name, clawName: name,
city: geo?.city ?? null, city: geo?.city ?? null,
country: geo?.country ?? null, country: geo?.country ?? null,
}); });
return NextResponse.json({ return NextResponse.json({
lobsterId, clawId,
apiKey, apiKey,
endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`, endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`,
}); });

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { gte, sql } from "drizzle-orm"; import { gte, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema"; import { claws, tasks } from "@/lib/db/schema";
import { import {
redis, redis,
getGlobalStats, getGlobalStats,
@@ -23,16 +23,16 @@ export async function GET() {
const now = Date.now(); const now = Date.now();
const fiveMinutesAgo = now - 300_000; const fiveMinutesAgo = now - 300_000;
const activeLobsters = await redis.zcount( const activeClaws = await redis.zcount(
"active:lobsters", "active:claws",
fiveMinutesAgo, fiveMinutesAgo,
"+inf" "+inf"
); );
const totalLobstersResult = await db const totalClawsResult = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(lobsters); .from(claws);
const totalLobsters = totalLobstersResult[0]?.count ?? 0; const totalClaws = totalClawsResult[0]?.count ?? 0;
const todayStart = new Date(); const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0); todayStart.setHours(0, 0, 0, 0);
@@ -58,8 +58,8 @@ export async function GET() {
} }
return NextResponse.json({ return NextResponse.json({
totalLobsters, totalClaws,
activeLobsters, activeClaws,
tasksToday, tasksToday,
tasksTotal: parseInt(globalStats.total_tasks ?? "0", 10), tasksTotal: parseInt(globalStats.total_tasks ?? "0", 10),
avgTaskDuration, avgTaskDuration,

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters, tasks } from "@/lib/db/schema"; import { claws, tasks } from "@/lib/db/schema";
import { import {
incrementGlobalStat, incrementGlobalStat,
incrementHourlyActivity, incrementHourlyActivity,
@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
} }
const apiKey = authHeader.slice(7); const apiKey = authHeader.slice(7);
const lobster = await validateApiKey(apiKey); const claw = await validateApiKey(apiKey);
if (!lobster) { if (!claw) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
} }
@@ -39,7 +39,7 @@ export async function POST(req: NextRequest) {
const now = new Date(); const now = new Date();
const insertResult = await db.insert(tasks).values({ const insertResult = await db.insert(tasks).values({
lobsterId: lobster.id, clawId: claw.id,
summary, summary,
durationMs, durationMs,
model: model ?? null, model: model ?? null,
@@ -48,9 +48,9 @@ export async function POST(req: NextRequest) {
}); });
await db await db
.update(lobsters) .update(claws)
.set({ totalTasks: sql`${lobsters.totalTasks} + 1`, updatedAt: now }) .set({ totalTasks: sql`${claws.totalTasks} + 1`, updatedAt: now })
.where(eq(lobsters.id, lobster.id)); .where(eq(claws.id, claw.id));
await incrementGlobalStat("total_tasks"); await incrementGlobalStat("total_tasks");
await incrementGlobalStat("tasks_today"); await incrementGlobalStat("tasks_today");
@@ -58,10 +58,10 @@ export async function POST(req: NextRequest) {
await publishEvent({ await publishEvent({
type: "task", type: "task",
lobsterId: lobster.id, clawId: claw.id,
lobsterName: lobster.name, clawName: claw.name,
city: lobster.city, city: claw.city,
country: lobster.country, country: claw.country,
summary, summary,
durationMs, durationMs,
}); });

View File

@@ -1,35 +1,7 @@
import type { Metadata } from "next"; import type { ReactNode } from "react";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const inter = Inter({ // Root layout delegates rendering of <html>/<body> to app/[locale]/layout.tsx.
subsets: ["latin"], // This file exists only to satisfy Next.js's requirement for a root layout.
variable: "--font-inter", export default function RootLayout({ children }: { children: ReactNode }) {
}); return children;
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata: Metadata = {
title: "OpenClaw Market - Global Lobster Activity",
description: "Real-time visualization of AI agent activity worldwide",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{children}
</body>
</html>
);
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -17,6 +18,7 @@ interface HourlyData {
} }
export function ActivityTimeline() { export function ActivityTimeline() {
const t = useTranslations("activityTimeline");
const [data, setData] = useState<HourlyData[]>([]); const [data, setData] = useState<HourlyData[]>([]);
useEffect(() => { useEffect(() => {
@@ -40,7 +42,7 @@ export function ActivityTimeline() {
return ( return (
<Card className="border-white/5"> <Card className="border-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle>24h Activity</CardTitle> <CardTitle>{t("title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[160px] w-full"> <div className="h-[160px] w-full">

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useTranslations, useLocale } from "next-intl";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -9,7 +10,7 @@ import { useSSE } from "@/hooks/use-sse";
interface FeedItem { interface FeedItem {
id: string; id: string;
type: "task" | "online" | "offline"; type: "task" | "online" | "offline";
lobsterName: string; clawName: string;
city?: string; city?: string;
country?: string; country?: string;
summary?: string; summary?: string;
@@ -17,7 +18,9 @@ interface FeedItem {
timestamp: number; timestamp: number;
} }
export function LobsterFeed() { export function ClawFeed() {
const t = useTranslations("clawFeed");
const locale = useLocale();
const [items, setItems] = useState<FeedItem[]>([]); const [items, setItems] = useState<FeedItem[]>([]);
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => { const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
@@ -25,7 +28,7 @@ export function LobsterFeed() {
const newItem: FeedItem = { const newItem: FeedItem = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: event.type as FeedItem["type"], type: event.type as FeedItem["type"],
lobsterName: (event.data.lobsterName as string) ?? "Unknown", clawName: (event.data.clawName as string) ?? "Unknown",
city: event.data.city as string | undefined, city: event.data.city as string | undefined,
country: event.data.country as string | undefined, country: event.data.country as string | undefined,
summary: event.data.summary as string | undefined, summary: event.data.summary as string | undefined,
@@ -46,17 +49,17 @@ export function LobsterFeed() {
useEffect(() => { useEffect(() => {
const fetchRecent = async () => { const fetchRecent = async () => {
try { try {
const res = await fetch("/api/v1/lobsters?limit=10"); const res = await fetch("/api/v1/claws?limit=10");
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const feedItems: FeedItem[] = (data.lobsters ?? []) const feedItems: FeedItem[] = (data.claws ?? [])
.filter((l: Record<string, unknown>) => l.lastTask) .filter((l: Record<string, unknown>) => l.lastTask)
.map((l: Record<string, unknown>) => { .map((l: Record<string, unknown>) => {
const task = l.lastTask as Record<string, unknown>; const task = l.lastTask as Record<string, unknown>;
return { return {
id: `init-${l.id}`, id: `init-${l.id}`,
type: "task" as const, type: "task" as const,
lobsterName: l.name as string, clawName: l.name as string,
city: l.city as string, city: l.city as string,
country: l.country as string, country: l.country as string,
summary: task.summary as string, summary: task.summary as string,
@@ -95,13 +98,13 @@ export function LobsterFeed() {
return ( return (
<Card className="border-white/5"> <Card className="border-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle>Live Feed</CardTitle> <CardTitle>{t("title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0"> <CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="py-8 text-center text-xs text-[var(--text-muted)]"> <p className="py-8 text-center text-xs text-[var(--text-muted)]">
Waiting for lobster activity... {t("waiting")}
</p> </p>
) : ( ) : (
items.map((item) => ( items.map((item) => (
@@ -118,7 +121,7 @@ export function LobsterFeed() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]"> <span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
{item.lobsterName} {item.clawName}
</span> </span>
{item.city && ( {item.city && (
<span className="text-xs text-[var(--text-muted)]"> <span className="text-xs text-[var(--text-muted)]">
@@ -136,7 +139,7 @@ export function LobsterFeed() {
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge> <Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
)} )}
<span className="text-[10px] text-[var(--text-muted)]"> <span className="text-[10px] text-[var(--text-muted)]">
{new Date(item.timestamp).toLocaleTimeString()} {new Date(item.timestamp).toLocaleTimeString(locale)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@@ -18,7 +19,17 @@ const regionColors: Record<string, string> = {
Oceania: "var(--accent-green)", Oceania: "var(--accent-green)",
}; };
const regionNameToKey: Record<string, string> = {
Asia: "asia",
Europe: "europe",
Americas: "americas",
Africa: "africa",
Oceania: "oceania",
};
export function RegionRanking() { export function RegionRanking() {
const t = useTranslations("regionRanking");
const tContinents = useTranslations("continents");
const [regions, setRegions] = useState<RegionData[]>([]); const [regions, setRegions] = useState<RegionData[]>([]);
useEffect(() => { useEffect(() => {
@@ -52,11 +63,11 @@ export function RegionRanking() {
return ( return (
<Card className="border-white/5"> <Card className="border-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle>Region Ranking</CardTitle> <CardTitle>{t("title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 p-4 pt-0"> <CardContent className="space-y-3 p-4 pt-0">
{regions.length === 0 ? ( {regions.length === 0 ? (
<p className="py-4 text-center text-xs text-[var(--text-muted)]">No data yet</p> <p className="py-4 text-center text-xs text-[var(--text-muted)]">{t("noData")}</p>
) : ( ) : (
regions.map((region, i) => ( regions.map((region, i) => (
<div key={region.name} className="space-y-1"> <div key={region.name} className="space-y-1">
@@ -66,7 +77,9 @@ export function RegionRanking() {
#{i + 1} #{i + 1}
</span> </span>
<span className="text-sm font-medium" style={{ color: region.color }}> <span className="text-sm font-medium" style={{ color: region.color }}>
{region.name} {regionNameToKey[region.name]
? tContinents(regionNameToKey[region.name] as "asia" | "europe" | "americas" | "africa" | "oceania")
: region.name}
</span> </span>
</div> </div>
<span className="font-mono text-xs text-[var(--text-secondary)]"> <span className="font-mono text-xs text-[var(--text-secondary)]">

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Users, Zap, Clock, Activity } from "lucide-react"; import { Users, Zap, Clock, Activity } from "lucide-react";
interface Stats { interface Stats {
totalLobsters: number; totalClaws: number;
activeLobsters: number; activeClaws: number;
tasksToday: number; tasksToday: number;
tasksTotal: number; tasksTotal: number;
avgTaskDuration: number; avgTaskDuration: number;
@@ -42,8 +43,9 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
requestAnimationFrame(animate); requestAnimationFrame(animate);
}, [value]); }, [value]);
const locale = useLocale();
const formatted = Number.isInteger(value) const formatted = Number.isInteger(value)
? Math.round(display).toLocaleString() ? Math.round(display).toLocaleString(locale)
: display.toFixed(1); : display.toFixed(1);
return ( return (
@@ -56,29 +58,29 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
const statCards = [ const statCards = [
{ {
key: "activeLobsters" as const, key: "activeClaws" as const,
label: "Online Now", labelKey: "onlineNow" as const,
icon: Activity, icon: Activity,
color: "var(--accent-green)", color: "var(--accent-green)",
glow: "0 0 20px rgba(16, 185, 129, 0.3)", glow: "0 0 20px rgba(16, 185, 129, 0.3)",
}, },
{ {
key: "totalLobsters" as const, key: "totalClaws" as const,
label: "Total Lobsters", labelKey: "totalClaws" as const,
icon: Users, icon: Users,
color: "var(--accent-cyan)", color: "var(--accent-cyan)",
glow: "var(--glow-cyan)", glow: "var(--glow-cyan)",
}, },
{ {
key: "tasksToday" as const, key: "tasksToday" as const,
label: "Tasks Today", labelKey: "tasksToday" as const,
icon: Zap, icon: Zap,
color: "var(--accent-purple)", color: "var(--accent-purple)",
glow: "var(--glow-purple)", glow: "var(--glow-purple)",
}, },
{ {
key: "avgTaskDuration" as const, key: "avgTaskDuration" as const,
label: "Avg Duration", labelKey: "avgDuration" as const,
icon: Clock, icon: Clock,
color: "var(--accent-orange)", color: "var(--accent-orange)",
glow: "0 0 20px rgba(245, 158, 11, 0.3)", glow: "0 0 20px rgba(245, 158, 11, 0.3)",
@@ -88,9 +90,10 @@ const statCards = [
]; ];
export function StatsPanel() { export function StatsPanel() {
const t = useTranslations("stats");
const [stats, setStats] = useState<Stats>({ const [stats, setStats] = useState<Stats>({
totalLobsters: 0, totalClaws: 0,
activeLobsters: 0, activeClaws: 0,
tasksToday: 0, tasksToday: 0,
tasksTotal: 0, tasksTotal: 0,
avgTaskDuration: 0, avgTaskDuration: 0,
@@ -116,7 +119,7 @@ export function StatsPanel() {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{statCards.map(({ key, label, icon: Icon, color, glow, suffix, transform }) => { {statCards.map(({ key, labelKey, icon: Icon, color, glow, suffix, transform }) => {
const raw = stats[key]; const raw = stats[key];
const value = transform ? transform(raw) : raw; const value = transform ? transform(raw) : raw;
return ( return (
@@ -133,7 +136,7 @@ export function StatsPanel() {
<Icon className="h-5 w-5" style={{ color }} /> <Icon className="h-5 w-5" style={{ color }} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-[var(--text-muted)]">{label}</p> <p className="text-xs text-[var(--text-muted)]">{t(labelKey)}</p>
<p <p
className="font-mono text-xl font-bold" className="font-mono text-xl font-bold"
style={{ color, textShadow: glow }} style={{ color, textShadow: glow }}

View File

@@ -1,15 +1,17 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
interface LobsterTooltipProps { interface ClawTooltipProps {
city: string; city: string;
country: string; country: string;
lobsterCount: number; clawCount: number;
weight: number; weight: number;
} }
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) { export function ClawTooltip({ city, country, clawCount, weight }: ClawTooltipProps) {
const t = useTranslations("continentMap");
return ( return (
<div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm"> <div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -20,8 +22,8 @@ export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterT
</div> </div>
</div> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<Badge variant="online">{lobsterCount} active</Badge> <Badge variant="online">{t("active", { count: clawCount })}</Badge>
<Badge variant="secondary">weight: {weight.toFixed(1)}</Badge> <Badge variant="secondary">{t("weight", { value: weight.toFixed(1) })}</Badge>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react"; import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslations } from "next-intl";
interface GlobeControlsProps { interface GlobeControlsProps {
onResetView: () => void; onResetView: () => void;
@@ -9,26 +10,27 @@ interface GlobeControlsProps {
} }
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) { export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
const t = useTranslations("globeControls");
return ( return (
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2"> <div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
<button <button
onClick={onZoomIn} onClick={onZoomIn}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]" className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Zoom in" aria-label={t("zoomIn")}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
</button> </button>
<button <button
onClick={onZoomOut} onClick={onZoomOut}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]" className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Zoom out" aria-label={t("zoomOut")}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
</button> </button>
<button <button
onClick={onResetView} onClick={onResetView}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]" className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
aria-label="Reset view" aria-label={t("resetView")}
> >
<RotateCw className="h-4 w-4" /> <RotateCw className="h-4 w-4" />
</button> </button>

View File

@@ -2,19 +2,25 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data"; import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
import { GlobeControls } from "./globe-controls"; import { GlobeControls } from "./globe-controls";
const Globe = dynamic(() => import("react-globe.gl"), { function GlobeLoading() {
ssr: false, const t = useTranslations("globe");
loading: () => ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
<span className="font-mono text-xs text-[var(--text-muted)]">Loading globe...</span> <span className="font-mono text-xs text-[var(--text-muted)]">{t("loading")}</span>
</div> </div>
</div> </div>
), );
}
const Globe = dynamic(() => import("react-globe.gl"), {
ssr: false,
loading: () => <GlobeLoading />,
}); });
interface ArcData { interface ArcData {
@@ -26,6 +32,7 @@ interface ArcData {
} }
export function GlobeView() { export function GlobeView() {
const t = useTranslations("globe");
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const globeRef = useRef<any>(undefined); const globeRef = useRef<any>(undefined);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -143,7 +150,7 @@ export function GlobeView() {
</div> </div>
</div> </div>
<p className="mt-1 text-xs text-[var(--text-secondary)]"> <p className="mt-1 text-xs text-[var(--text-secondary)]">
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""} {t("activeClaws", { count: hoveredPoint.clawCount })}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -2,10 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { Check, Copy, Terminal } from "lucide-react"; import { Check, Copy, Terminal } from "lucide-react";
import { useTranslations } from "next-intl";
const INSTALL_COMMAND = "clawhub install openclaw-reporter"; const INSTALL_COMMAND = "clawhub install openclaw-reporter";
export function InstallBanner() { export function InstallBanner() {
const t = useTranslations("installBanner");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = async () => { const handleCopy = async () => {
@@ -31,10 +33,10 @@ export function InstallBanner() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]"> <p className="text-sm font-medium text-[var(--text-primary)]">
Join the Heatmap {t("title")}
</p> </p>
<p className="text-xs text-[var(--text-muted)] truncate"> <p className="text-xs text-[var(--text-muted)] truncate">
Install the skill and let your lobster light up the globe {t("subtitle")}
</p> </p>
</div> </div>
</div> </div>
@@ -43,7 +45,7 @@ export function InstallBanner() {
<button <button
onClick={handleCopy} onClick={handleCopy}
className="group flex items-center gap-2 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-4 py-2.5 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0" className="group flex items-center gap-2 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-4 py-2.5 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0"
title="Click to copy" title={t("copyTooltip")}
> >
<Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" /> <Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" />
<code className="font-mono text-sm text-[var(--accent-cyan)] select-all"> <code className="font-mono text-sm text-[var(--accent-cyan)] select-all">

View File

@@ -0,0 +1,38 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "@/i18n/navigation";
import { Globe } from "lucide-react";
import { cn } from "@/lib/utils";
import { routing } from "@/i18n/routing";
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("languageSwitcher");
const switchLocale = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-0.5">
<Globe className="mx-1 h-3 w-3 text-[var(--text-muted)]" />
{routing.locales.map((l) => (
<button
key={l}
onClick={() => switchLocale(l)}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-all cursor-pointer",
locale === l
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
{t(l)}
</button>
))}
</div>
);
}

View File

@@ -1,14 +1,18 @@
"use client"; "use client";
import Link from "next/link";
import { Activity, Globe2, Map } from "lucide-react"; import { Activity, Globe2, Map } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "./language-switcher";
interface NavbarProps { interface NavbarProps {
activeView?: "globe" | "map"; activeView?: "globe" | "map";
} }
export function Navbar({ activeView = "globe" }: NavbarProps) { export function Navbar({ activeView = "globe" }: NavbarProps) {
const t = useTranslations("navbar");
return ( return (
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-[var(--bg-primary)]/80 backdrop-blur-xl"> <nav className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-[var(--bg-primary)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-[1800px] items-center justify-between px-4"> <div className="mx-auto flex h-14 max-w-[1800px] items-center justify-between px-4">
@@ -18,7 +22,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
className="font-mono text-lg font-bold tracking-tight" className="font-mono text-lg font-bold tracking-tight"
style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }} style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }}
> >
OpenClaw Market {t("brand")}
</span> </span>
</Link> </Link>
@@ -33,7 +37,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
)} )}
> >
<Globe2 className="h-3.5 w-3.5" /> <Globe2 className="h-3.5 w-3.5" />
3D Globe {t("globe")}
</Link> </Link>
<Link <Link
href="/continent/asia" href="/continent/asia"
@@ -45,15 +49,16 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
)} )}
> >
<Map className="h-3.5 w-3.5" /> <Map className="h-3.5 w-3.5" />
2D Map {t("map")}
</Link> </Link>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5 text-[var(--accent-green)]" /> <Activity className="h-3.5 w-3.5 text-[var(--accent-green)]" />
<span className="text-xs text-[var(--text-secondary)]">Live</span> <span className="text-xs text-[var(--text-secondary)]">{t("live")}</span>
</div> </div>
<LanguageSwitcher />
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -1,22 +1,20 @@
"use client"; "use client";
import Link from "next/link";
import { Globe2, Map } from "lucide-react"; import { Globe2, Map } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const continents = [ const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
{ slug: "asia", label: "Asia" },
{ slug: "europe", label: "Europe" },
{ slug: "americas", label: "Americas" },
{ slug: "africa", label: "Africa" },
{ slug: "oceania", label: "Oceania" },
];
interface ViewSwitcherProps { interface ViewSwitcherProps {
activeContinent?: string; activeContinent?: string;
} }
export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) { export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
const tSwitcher = useTranslations("viewSwitcher");
const tContinents = useTranslations("continents");
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
@@ -29,21 +27,21 @@ export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
)} )}
> >
<Globe2 className="h-3 w-3" /> <Globe2 className="h-3 w-3" />
Global {tSwitcher("global")}
</Link> </Link>
{continents.map((c) => ( {continentSlugs.map((slug) => (
<Link <Link
key={c.slug} key={slug}
href={`/continent/${c.slug}`} href={`/continent/${slug}`}
className={cn( className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all", "flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
activeContinent === c.slug activeContinent === slug
? "border-[var(--accent-purple)]/30 bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]" ? "border-[var(--accent-purple)]/30 bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]"
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]" : "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
)} )}
> >
<Map className="h-3 w-3" /> <Map className="h-3 w-3" />
{c.label} {tContinents(slug)}
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { import {
ComposableMap, ComposableMap,
Geographies, Geographies,
@@ -18,15 +19,14 @@ const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
interface ContinentConfig { interface ContinentConfig {
center: [number, number]; center: [number, number];
zoom: number; zoom: number;
label: string;
} }
const continentConfigs: Record<string, ContinentConfig> = { const continentConfigs: Record<string, ContinentConfig> = {
asia: { center: [100, 35], zoom: 2.5, label: "Asia" }, asia: { center: [100, 35], zoom: 2.5 },
europe: { center: [15, 52], zoom: 4, label: "Europe" }, europe: { center: [15, 52], zoom: 4 },
americas: { center: [-80, 15], zoom: 1.8, label: "Americas" }, americas: { center: [-80, 15], zoom: 1.8 },
africa: { center: [20, 5], zoom: 2.2, label: "Africa" }, africa: { center: [20, 5], zoom: 2.2 },
oceania: { center: [145, -25], zoom: 3, label: "Oceania" }, oceania: { center: [145, -25], zoom: 3 },
}; };
const continentRegionMap: Record<string, string> = { const continentRegionMap: Record<string, string> = {
@@ -42,6 +42,7 @@ interface ContinentMapProps {
} }
export function ContinentMap({ slug }: ContinentMapProps) { export function ContinentMap({ slug }: ContinentMapProps) {
const t = useTranslations("continentMap");
const config = continentConfigs[slug] ?? continentConfigs.asia; const config = continentConfigs[slug] ?? continentConfigs.asia;
const regionFilter = continentRegionMap[slug]; const regionFilter = continentRegionMap[slug];
const { points } = useHeatmapData(30000); const { points } = useHeatmapData(30000);
@@ -125,8 +126,8 @@ export function ContinentMap({ slug }: ContinentMapProps) {
</button> </button>
</div> </div>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Badge variant="online">{selectedPoint.lobsterCount} lobsters</Badge> <Badge variant="online">{t("claws", { count: selectedPoint.clawCount })}</Badge>
<Badge variant="secondary">weight: {selectedPoint.weight.toFixed(1)}</Badge> <Badge variant="secondary">{t("weight", { value: selectedPoint.weight.toFixed(1) })}</Badge>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -46,7 +46,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
onClick={() => onPointClick?.(point)} onClick={() => onPointClick?.(point)}
/> />
{/* Count label */} {/* Count label */}
{point.lobsterCount > 1 && ( {point.clawCount > 1 && (
<text <text
x={x} x={x}
y={y - radius - 4} y={y - radius - 4}
@@ -55,7 +55,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
fontSize={9} fontSize={9}
fontFamily="var(--font-mono)" fontFamily="var(--font-mono)"
> >
{point.lobsterCount} {point.clawCount}
</text> </text>
)} )}
</g> </g>

View File

@@ -6,7 +6,7 @@ export interface HeatmapPoint {
lat: number; lat: number;
lng: number; lng: number;
weight: number; weight: number;
lobsterCount: number; clawCount: number;
city: string; city: string;
country: string; country: string;
} }

5
i18n/navigation.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

15
i18n/request.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "en" | "zh")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});

7
i18n/routing.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "zh"],
defaultLocale: "en",
localePrefix: "always",
});

View File

@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { lobsters } from "@/lib/db/schema"; import { claws } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
@@ -12,34 +12,34 @@ export function generateApiKey(): string {
export async function validateApiKey(apiKey: string) { export async function validateApiKey(apiKey: string) {
try { try {
const cacheKey = `lobster:key:${apiKey}`; const cacheKey = `claw:key:${apiKey}`;
const cachedLobsterId = await redis.get(cacheKey); const cachedClawId = await redis.get(cacheKey);
if (cachedLobsterId) { if (cachedClawId) {
const lobster = await db const claw = await db
.select() .select()
.from(lobsters) .from(claws)
.where(eq(lobsters.id, cachedLobsterId)) .where(eq(claws.id, cachedClawId))
.limit(1); .limit(1);
if (lobster.length > 0) { if (claw.length > 0) {
return lobster[0]; return claw[0];
} }
} }
const lobster = await db const claw = await db
.select() .select()
.from(lobsters) .from(claws)
.where(eq(lobsters.apiKey, apiKey)) .where(eq(claws.apiKey, apiKey))
.limit(1); .limit(1);
if (lobster.length === 0) { if (claw.length === 0) {
return null; return null;
} }
await redis.set(cacheKey, lobster[0].id, "EX", API_KEY_CACHE_TTL); await redis.set(cacheKey, claw[0].id, "EX", API_KEY_CACHE_TTL);
return lobster[0]; return claw[0];
} catch (error) { } catch (error) {
console.error("Failed to validate API key:", error); console.error("Failed to validate API key:", error);
return null; return null;

View File

@@ -10,7 +10,7 @@ import {
} from "drizzle-orm/mysql-core"; } from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
export const lobsters = mysqlTable("lobsters", { export const claws = mysqlTable("claws", {
id: varchar("id", { length: 21 }).primaryKey(), id: varchar("id", { length: 21 }).primaryKey(),
apiKey: varchar("api_key", { length: 64 }).notNull().unique(), apiKey: varchar("api_key", { length: 64 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(), name: varchar("name", { length: 100 }).notNull(),
@@ -33,12 +33,12 @@ export const heartbeats = mysqlTable(
"heartbeats", "heartbeats",
{ {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(), id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
lobsterId: varchar("lobster_id", { length: 21 }).notNull(), clawId: varchar("claw_id", { length: 21 }).notNull(),
ip: varchar("ip", { length: 45 }), ip: varchar("ip", { length: 45 }),
timestamp: datetime("timestamp").default(sql`NOW()`), timestamp: datetime("timestamp").default(sql`NOW()`),
}, },
(table) => [ (table) => [
index("heartbeats_lobster_id_idx").on(table.lobsterId), index("heartbeats_claw_id_idx").on(table.clawId),
index("heartbeats_timestamp_idx").on(table.timestamp), index("heartbeats_timestamp_idx").on(table.timestamp),
] ]
); );
@@ -47,7 +47,7 @@ export const tasks = mysqlTable(
"tasks", "tasks",
{ {
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(), id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
lobsterId: varchar("lobster_id", { length: 21 }).notNull(), clawId: varchar("claw_id", { length: 21 }).notNull(),
summary: varchar("summary", { length: 500 }), summary: varchar("summary", { length: 500 }),
durationMs: int("duration_ms"), durationMs: int("duration_ms"),
model: varchar("model", { length: 50 }), model: varchar("model", { length: 50 }),
@@ -55,7 +55,7 @@ export const tasks = mysqlTable(
timestamp: datetime("timestamp").default(sql`NOW()`), timestamp: datetime("timestamp").default(sql`NOW()`),
}, },
(table) => [ (table) => [
index("tasks_lobster_id_idx").on(table.lobsterId), index("tasks_claw_id_idx").on(table.clawId),
index("tasks_timestamp_idx").on(table.timestamp), index("tasks_timestamp_idx").on(table.timestamp),
] ]
); );

View File

@@ -24,33 +24,33 @@ if (process.env.NODE_ENV !== "production") {
} }
const CHANNEL_REALTIME = "channel:realtime"; const CHANNEL_REALTIME = "channel:realtime";
const ACTIVE_LOBSTERS_KEY = "active:lobsters"; const ACTIVE_CLAWS_KEY = "active:claws";
const STATS_GLOBAL_KEY = "stats:global"; const STATS_GLOBAL_KEY = "stats:global";
const STATS_REGION_KEY = "stats:region"; const STATS_REGION_KEY = "stats:region";
const HEATMAP_CACHE_KEY = "cache:heatmap"; const HEATMAP_CACHE_KEY = "cache:heatmap";
const HOURLY_ACTIVITY_KEY = "stats:hourly"; const HOURLY_ACTIVITY_KEY = "stats:hourly";
export async function setLobsterOnline( export async function setClawOnline(
lobsterId: string, clawId: string,
ip: string ip: string
): Promise<void> { ): Promise<void> {
await redis.set(`lobster:online:${lobsterId}`, ip, "EX", 300); await redis.set(`claw:online:${clawId}`, ip, "EX", 300);
} }
export async function isLobsterOnline(lobsterId: string): Promise<boolean> { export async function isClawOnline(clawId: string): Promise<boolean> {
const result = await redis.exists(`lobster:online:${lobsterId}`); const result = await redis.exists(`claw:online:${clawId}`);
return result === 1; return result === 1;
} }
export async function updateActiveLobsters(lobsterId: string): Promise<void> { export async function updateActiveClaws(clawId: string): Promise<void> {
const now = Date.now(); const now = Date.now();
await redis.zadd(ACTIVE_LOBSTERS_KEY, now, lobsterId); await redis.zadd(ACTIVE_CLAWS_KEY, now, clawId);
} }
export async function getActiveLobsterIds( export async function getActiveClawIds(
limit: number = 100 limit: number = 100
): Promise<string[]> { ): Promise<string[]> {
return redis.zrevrange(ACTIVE_LOBSTERS_KEY, 0, limit - 1); return redis.zrevrange(ACTIVE_CLAWS_KEY, 0, limit - 1);
} }
export async function incrementRegionCount(region: string): Promise<void> { export async function incrementRegionCount(region: string): Promise<void> {

65
messages/en.json Normal file
View File

@@ -0,0 +1,65 @@
{
"metadata": {
"title": "OpenClaw Market - Global Claw Activity",
"description": "Real-time visualization of AI agent activity worldwide"
},
"navbar": {
"brand": "OpenClaw Market",
"globe": "3D Globe",
"map": "2D Map",
"live": "Live"
},
"installBanner": {
"title": "Join the Heatmap",
"subtitle": "Install the skill and let your claw light up the globe",
"copyTooltip": "Click to copy"
},
"stats": {
"onlineNow": "Online Now",
"totalClaws": "Total Claws",
"tasksToday": "Tasks Today",
"avgDuration": "Avg Duration"
},
"regionRanking": {
"title": "Region Ranking",
"noData": "No data yet"
},
"clawFeed": {
"title": "Live Feed",
"waiting": "Waiting for claw activity..."
},
"activityTimeline": {
"title": "24h Activity"
},
"globe": {
"loading": "Loading globe...",
"activeClaws": "{count, plural, one {# active claw} other {# active claws}}"
},
"globeControls": {
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"resetView": "Reset view"
},
"viewSwitcher": {
"global": "Global"
},
"continents": {
"asia": "Asia",
"europe": "Europe",
"americas": "Americas",
"africa": "Africa",
"oceania": "Oceania"
},
"continentPage": {
"regionTitle": "{name} Region"
},
"continentMap": {
"claws": "{count} claws",
"active": "{count} active",
"weight": "weight: {value}"
},
"languageSwitcher": {
"en": "EN",
"zh": "中文"
}
}

65
messages/zh.json Normal file
View File

@@ -0,0 +1,65 @@
{
"metadata": {
"title": "OpenClaw Market - 全球龙虾活动",
"description": "全球 AI 代理活动实时可视化"
},
"navbar": {
"brand": "OpenClaw Market",
"globe": "3D 地球",
"map": "2D 地图",
"live": "实时"
},
"installBanner": {
"title": "加入热力图",
"subtitle": "安装技能,让你的龙虾点亮全球",
"copyTooltip": "点击复制"
},
"stats": {
"onlineNow": "当前在线",
"totalClaws": "总龙虾数",
"tasksToday": "今日任务",
"avgDuration": "平均时长"
},
"regionRanking": {
"title": "区域排名",
"noData": "暂无数据"
},
"clawFeed": {
"title": "实时动态",
"waiting": "等待龙虾活动中..."
},
"activityTimeline": {
"title": "24小时活动"
},
"globe": {
"loading": "正在加载地球...",
"activeClaws": "{count, plural, other {# 只活跃龙虾}}"
},
"globeControls": {
"zoomIn": "放大",
"zoomOut": "缩小",
"resetView": "重置视角"
},
"viewSwitcher": {
"global": "全球"
},
"continents": {
"asia": "亚洲",
"europe": "欧洲",
"americas": "美洲",
"africa": "非洲",
"oceania": "大洋洲"
},
"continentPage": {
"regionTitle": "{name}区域"
},
"continentMap": {
"claws": "{count} 只龙虾",
"active": "{count} 活跃",
"weight": "权重:{value}"
},
"languageSwitcher": {
"en": "EN",
"zh": "中文"
}
}

8
middleware.ts Normal file
View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

View File

@@ -1,7 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ["react-globe.gl", "globe.gl", "three"], transpilePackages: ["react-globe.gl", "globe.gl", "three"],
}; };
export default nextConfig; export default withNextIntl(nextConfig);

View File

@@ -13,41 +13,42 @@
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"next": "^15.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^3.0.2",
"lucide-react": "^0.474.0",
"react-globe.gl": "^2.27.3",
"three": "^0.173.0",
"react-simple-maps": "^3.0.0",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"topojson-client": "^3.1.0",
"framer-motion": "^12.6.0",
"recharts": "^2.15.3",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"mysql2": "^3.14.0", "framer-motion": "^12.6.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"zod": "^3.24.3", "lucide-react": "^0.474.0",
"nanoid": "^5.1.5" "mysql2": "^3.14.0",
"nanoid": "^5.1.5",
"next": "^15.3.0",
"next-intl": "^4.8.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-globe.gl": "^2.27.3",
"react-simple-maps": "^3.0.0",
"recharts": "^2.15.3",
"tailwind-merge": "^3.0.2",
"three": "^0.173.0",
"topojson-client": "^3.1.0",
"zod": "^3.24.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.0",
"@types/d3-geo": "^3.1.0",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/three": "^0.173.0", "@types/three": "^0.173.0",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@types/d3-geo": "^3.1.0", "drizzle-kit": "^0.30.6",
"typescript": "^5.8.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss": "^4.1.0",
"postcss": "^8.5.3",
"eslint": "^9.21.0", "eslint": "^9.21.0",
"eslint-config-next": "^15.3.0", "eslint-config-next": "^15.3.0",
"drizzle-kit": "^0.30.6", "postcss": "^8.5.3",
"@eslint/eslintrc": "^3.3.1" "tailwindcss": "^4.1.0",
"typescript": "^5.8.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

402
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
next: next:
specifier: ^15.3.0 specifier: ^15.3.0
version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl:
specifier: ^4.8.3
version: 4.8.3(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.2.4 version: 19.2.4
@@ -447,6 +450,21 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@formatjs/ecma402-abstract@3.1.1':
resolution: {integrity: sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==}
'@formatjs/fast-memoize@3.1.0':
resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==}
'@formatjs/icu-messageformat-parser@3.5.1':
resolution: {integrity: sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==}
'@formatjs/icu-skeleton-parser@2.1.1':
resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==}
'@formatjs/intl-localematcher@0.8.1':
resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -692,6 +710,88 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@petamoriken/float16@3.9.3': '@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
@@ -701,9 +801,87 @@ packages:
'@rushstack/eslint-patch@1.16.1': '@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@swc/core-darwin-arm64@1.15.18':
resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.18':
resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.18':
resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.18':
resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.18':
resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.18':
resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.18':
resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.18':
resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.18':
resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.18':
resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.18':
resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@tailwindcss/node@4.2.1': '@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
@@ -1341,6 +1519,9 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1830,6 +2011,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
icu-minify@4.8.3:
resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -1861,6 +2045,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
intl-messageformat@11.1.2:
resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==}
ioredis@5.10.0: ioredis@5.10.0:
resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@@ -2205,6 +2392,23 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-intl-swc-plugin-extractor@4.8.3:
resolution: {integrity: sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==}
next-intl@4.8.3:
resolution: {integrity: sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next@15.5.12: next@15.5.12:
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -2226,6 +2430,9 @@ packages:
sass: sass:
optional: true optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-exports-info@1.6.0: node-exports-info@1.6.0:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2304,6 +2511,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
point-in-polygon-hao@1.2.4: point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==} resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
@@ -2701,6 +2911,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-intl@4.8.3:
resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
victory-vendor@36.9.2: victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
@@ -2956,6 +3171,33 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@formatjs/ecma402-abstract@3.1.1':
dependencies:
'@formatjs/fast-memoize': 3.1.0
'@formatjs/intl-localematcher': 0.8.1
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@3.1.0':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@3.5.1':
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
'@formatjs/icu-skeleton-parser': 2.1.1
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@2.1.1':
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
tslib: 2.8.1
'@formatjs/intl-localematcher@0.8.1':
dependencies:
'@formatjs/fast-memoize': 3.1.0
tslib: 2.8.1
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7': '@humanfs/node@0.16.7':
@@ -3136,16 +3378,130 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@petamoriken/float16@3.9.3': {} '@petamoriken/float16@3.9.3': {}
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {} '@rushstack/eslint-patch@1.16.1': {}
'@schummar/icu-type-parser@1.21.5': {}
'@swc/core-darwin-arm64@1.15.18':
optional: true
'@swc/core-darwin-x64@1.15.18':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.18':
optional: true
'@swc/core-linux-arm64-gnu@1.15.18':
optional: true
'@swc/core-linux-arm64-musl@1.15.18':
optional: true
'@swc/core-linux-x64-gnu@1.15.18':
optional: true
'@swc/core-linux-x64-musl@1.15.18':
optional: true
'@swc/core-win32-arm64-msvc@1.15.18':
optional: true
'@swc/core-win32-ia32-msvc@1.15.18':
optional: true
'@swc/core-win32-x64-msvc@1.15.18':
optional: true
'@swc/core@1.15.18':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.25
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.18
'@swc/core-darwin-x64': 1.15.18
'@swc/core-linux-arm-gnueabihf': 1.15.18
'@swc/core-linux-arm64-gnu': 1.15.18
'@swc/core-linux-arm64-musl': 1.15.18
'@swc/core-linux-x64-gnu': 1.15.18
'@swc/core-linux-x64-musl': 1.15.18
'@swc/core-win32-arm64-msvc': 1.15.18
'@swc/core-win32-ia32-msvc': 1.15.18
'@swc/core-win32-x64-msvc': 1.15.18
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/node@4.2.1': '@tailwindcss/node@4.2.1':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -3787,6 +4143,8 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
define-data-property@1.1.4: define-data-property@1.1.4:
@@ -4395,6 +4753,10 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
icu-minify@4.8.3:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.1
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@@ -4418,6 +4780,13 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
intl-messageformat@11.1.2:
dependencies:
'@formatjs/ecma402-abstract': 3.1.1
'@formatjs/fast-memoize': 3.1.0
'@formatjs/icu-messageformat-parser': 3.5.1
tslib: 2.8.1
ioredis@5.10.0: ioredis@5.10.0:
dependencies: dependencies:
'@ioredis/commands': 1.5.1 '@ioredis/commands': 1.5.1
@@ -4739,6 +5108,27 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
negotiator@1.0.0: {}
next-intl-swc-plugin-extractor@4.8.3: {}
next-intl@4.8.3(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.8.1
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.18
icu-minify: 4.8.3
negotiator: 1.0.0
next: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-intl-swc-plugin-extractor: 4.8.3
po-parser: 2.1.1
react: 19.2.4
use-intl: 4.8.3(react@19.2.4)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@swc/helpers'
next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4): next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
'@next/env': 15.5.12 '@next/env': 15.5.12
@@ -4762,6 +5152,8 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-addon-api@7.1.1: {}
node-exports-info@1.6.0: node-exports-info@1.6.0:
dependencies: dependencies:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
@@ -4850,6 +5242,8 @@ snapshots:
picomatch@4.0.3: {} picomatch@4.0.3: {}
po-parser@2.1.1: {}
point-in-polygon-hao@1.2.4: point-in-polygon-hao@1.2.4:
dependencies: dependencies:
robust-predicates: 3.0.2 robust-predicates: 3.0.2
@@ -5379,6 +5773,14 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-intl@4.8.3(react@19.2.4):
dependencies:
'@formatjs/fast-memoize': 3.1.0
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.8.3
intl-messageformat: 11.1.2
react: 19.2.4
victory-vendor@36.9.2: victory-vendor@36.9.2:
dependencies: dependencies:
'@types/d3-array': 3.2.2 '@types/d3-array': 3.2.2

View File

@@ -1,11 +1,12 @@
--- ---
name: openclaw-reporter name: openclaw-reporter
description: | description: |
Opt-in reporter for the OpenClaw global lobster heatmap. Opt-in reporter for the OpenClaw global claw heatmap.
On first use, ASKS the user for consent before registering. On first use, ASKS the user for consent before registering.
Sends heartbeats (platform + model only) and generic task summaries to https://kymr.top/. Sends heartbeats (platform + model only) and generic task summaries to https://kymr.top/.
All network calls run synchronously in the foreground — output is visible to the user. Heartbeats are sent automatically via hooks at session start — no LLM action needed.
Data collected: user-chosen lobster name, OS platform, model name, generic task category. Task reports are sent automatically via hooks when the agent stops responding.
Data collected: user-chosen claw name, OS platform, model name, generic task category.
Credentials: the server returns an apiKey on registration, which is stored locally Credentials: the server returns an apiKey on registration, which is stored locally
in ~/.openclaw/config.json and sent as a Bearer token in subsequent requests. in ~/.openclaw/config.json and sent as a Bearer token in subsequent requests.
No file paths, code, or project-specific data is ever sent. No file paths, code, or project-specific data is ever sent.
@@ -21,11 +22,29 @@ requirements:
- name: CLAUDE_MODEL - name: CLAUDE_MODEL
required: false required: false
description: "Current Claude model identifier. Used to report which model is active. Falls back to 'unknown' if not set." description: "Current Claude model identifier. Used to report which model is active. Falls back to 'unknown' if not set."
hooks:
SessionStart:
- hooks:
- type: command
command: "if [ -f ~/.openclaw/config.json ]; then KEY=$(python3 -c \"import json; print(json.load(open('$HOME/.openclaw/config.json'))['apiKey'])\") && curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/heartbeat' -H 'Content-Type: application/json' -H \"Authorization: Bearer $KEY\" -d \"{\\\"platform\\\":\\\"$(uname -s | tr '[:upper:]' '[:lower:]')\\\",\\\"model\\\":\\\"${CLAUDE_MODEL:-unknown}\\\"}\"; fi"
timeout: 10
once: true
Stop:
- hooks:
- type: command
command: "if [ -f ~/.openclaw/config.json ]; then KEY=$(python3 -c \"import json; print(json.load(open('$HOME/.openclaw/config.json'))['apiKey'])\") && curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/task' -H 'Content-Type: application/json' -H \"Authorization: Bearer $KEY\" -d \"{\\\"summary\\\":\\\"Completed a task\\\",\\\"model\\\":\\\"${CLAUDE_MODEL:-unknown}\\\"}\"; fi"
timeout: 10
--- ---
# OpenClaw Reporter # OpenClaw Reporter
This skill lets you participate in the OpenClaw global lobster activity heatmap. It reports **anonymous, non-identifying** activity data to visualize worldwide AI agent usage. This skill lets you participate in the OpenClaw global claw activity heatmap. It reports **anonymous, non-identifying** activity data to visualize worldwide AI agent usage.
## How It Works
- **Heartbeat**: Sent automatically at session start via a `SessionStart` hook — no manual action needed.
- **Task report**: Sent automatically when the agent finishes responding via a `Stop` hook.
- **Registration**: One-time setup requiring explicit user consent (see below).
## Data Disclosure ## Data Disclosure
@@ -33,132 +52,78 @@ This skill sends the following data to `https://kymr.top/`:
| Data Field | Example | Purpose | | Data Field | Example | Purpose |
|---|---|---| |---|---|---|
| Lobster Name | `CoolLobster42` (user-chosen) | Your display name on the heatmap | | Claw Name | `CoolClaw42` (user-chosen) | Your display name on the heatmap |
| Platform | `darwin`, `linux` | OS type for heatmap stats | | Platform | `darwin`, `linux` | OS type for heatmap stats |
| Model | `claude-sonnet-4-6` | Model usage stats | | Model | `claude-sonnet-4-6` | Model usage stats |
| Task summary | `"Fixed a bug"` | Generic activity category | | Task summary | `"Completed a task"` | Generic activity indicator |
**Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json`. This token is sent as `Authorization: Bearer` header in heartbeat and task report requests. The `CLAUDE_MODEL` environment variable is read (if available) to identify the active model — it falls back to `"unknown"` if not set. **Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json` with `chmod 600` (owner-only access). This token is sent as `Authorization: Bearer` header in heartbeat and task report requests. The endpoint URL (`https://kymr.top/`) is hardcoded in hooks — not read from the config file — so a modified config cannot redirect telemetry. The `CLAUDE_MODEL` environment variable is read (if available) to identify the active model — it falls back to `"unknown"` if not set.
**Never sent:** system usernames, file paths, code snippets, project names, secrets, IP-derived geolocation beyond what the server naturally receives. **Never sent:** system usernames, file paths, code snippets, project names, secrets, IP-derived geolocation beyond what the server naturally receives.
## Configuration ## Configuration
Config is stored at `~/.openclaw/config.json`. The skill checks for this file at the start of each conversation. Config is stored at `~/.openclaw/config.json` (permissions `600`). The hooks check for this file before sending any requests — if it doesn't exist, nothing is sent. The endpoint is hardcoded to `https://kymr.top/` in the hook commands and is **not** configurable via the config file.
## First-Time Setup (Requires User Consent) ## First-Time Setup (Requires User Consent)
If `~/.openclaw/config.json` does not exist, follow these two steps: If `~/.openclaw/config.json` does not exist, follow these steps:
### Step 1: Ask for consent ### Step 1: Ask for consent
Use the AskUserQuestion tool: Use the AskUserQuestion tool:
- Question: "OpenClaw Reporter wants to register a lobster name and send activity heartbeats to https://kymr.top/ for the global heatmap. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, or project data is sent. Allow?" - Question: "OpenClaw Reporter wants to register a claw name and send activity heartbeats to https://kymr.top/ for the global heatmap. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, or project data is sent. Allow?"
- Options: "Yes, register" / "No, skip" - Options: "Yes, register" / "No, skip"
**If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here. **If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here.
### Step 2: Ask for a lobster name ### Step 2: Ask for a claw name
If the user consented, use AskUserQuestion again: If the user consented, use AskUserQuestion again:
- Question: "Pick a name for your lobster on the heatmap! (e.g. CoolLobster, NightCrawler, CodeClaw)" - Question: "Pick a name for your claw on the heatmap! (e.g. CoolClaw, NightCrawler, CodeClaw)"
- Let the user type a custom name via the "Other" option (no preset options needed, but provide a few fun suggestions as options) - Let the user type a custom name via the "Other" option (no preset options needed, but provide a few fun suggestions as options)
- Options: "CoolLobster" / "CodeClaw" / "NightCrawler" - Options: "CoolClaw" / "CodeClaw" / "NightCrawler"
Use whatever the user provides as the lobster name. Use whatever the user provides as the claw name.
### Step 3: Register ### Step 3: Register
After receiving the name (stored as `LOBSTER_NAME`): After receiving the name (stored as `CLAW_NAME`):
```bash ```bash
# Create config directory # Create config directory
mkdir -p ~/.openclaw mkdir -p ~/.openclaw
OPENCLAW_ENDPOINT="https://kymr.top" OPENCLAW_ENDPOINT="https://kymr.top"
LOBSTER_NAME="USER_CHOSEN_NAME_HERE" CLAW_NAME="USER_CHOSEN_NAME_HERE"
RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \ RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"name\": \"$LOBSTER_NAME\", \"name\": \"$CLAW_NAME\",
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\", \"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\" \"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
}") }")
# Save config # Save config (no endpoint stored — hooks use a hardcoded URL)
echo "$RESPONSE" | python3 -c " echo "$RESPONSE" | python3 -c "
import json, sys import json, sys, os
data = json.load(sys.stdin) data = json.load(sys.stdin)
config = { config = {
'lobsterId': data.get('lobsterId', ''), 'clawId': data.get('clawId', ''),
'apiKey': data.get('apiKey', ''), 'apiKey': data.get('apiKey', ''),
'name': '$LOBSTER_NAME', 'name': '$CLAW_NAME'
'endpoint': '$OPENCLAW_ENDPOINT'
} }
with open('$HOME/.openclaw/config.json', 'w') as f: path = os.path.expanduser('~/.openclaw/config.json')
with open(path, 'w') as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
print('Registered as:', '$LOBSTER_NAME') os.chmod(path, 0o600)
print('Registered as:', '$CLAW_NAME')
" "
``` ```
## Heartbeat (On Every Conversation Start)
If the config file exists, send a heartbeat. The request runs **synchronously in the foreground** with a 5-second timeout, so the user sees it happen:
```bash
if [ -f ~/.openclaw/config.json ]; then
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
curl -s -o /dev/null -w "OpenClaw heartbeat: HTTP %{http_code}\n" \
--max-time 5 \
-X POST "$OPENCLAW_ENDPOINT/api/v1/heartbeat" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENCLAW_KEY" \
-d "{
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
}"
fi
```
## Task Completion Report
When a significant task is completed, report a **generic summary only**:
```bash
if [ -f ~/.openclaw/config.json ]; then
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
curl -s -o /dev/null -w "OpenClaw task report: HTTP %{http_code}\n" \
--max-time 5 \
-X POST "$OPENCLAW_ENDPOINT/api/v1/task" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENCLAW_KEY" \
-d "{
\"summary\": \"TASK_SUMMARY_HERE\",
\"durationMs\": DURATION_MS_HERE,
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\",
\"toolsUsed\": [\"LIST_OF_TOOLS\"]
}"
fi
```
### Task Summary Rules (STRICT)
- NEVER include file paths, variable names, or code snippets
- NEVER include API keys, secrets, or credentials
- NEVER include project names, repository names, or organization names
- NEVER include personal information (names, emails, usernames)
- ONLY use generic categories: "Implemented a feature", "Fixed a bug", "Refactored code", "Updated configuration", "Added tests"
- Keep summaries under 100 characters
## Opt-Out / Unregister ## Opt-Out / Unregister
To stop all OpenClaw reporting, delete the config file: To stop all OpenClaw reporting, delete the config file:
@@ -168,13 +133,12 @@ rm -f ~/.openclaw/config.json
rmdir ~/.openclaw 2>/dev/null rmdir ~/.openclaw 2>/dev/null
``` ```
After deletion, no heartbeats or task reports will be sent. The skill will ask for consent again if re-enabled. After deletion, no heartbeats or task reports will be sent. The hooks silently skip when the config file is absent. The skill will ask for consent again if re-enabled.
## Behavior Rules ## Behavior Rules
1. **User consent is REQUIRED** before first registration — never register silently 1. **User consent is REQUIRED** before first registration — never register silently
2. If the user declines registration, respect the decision and do not ask again in the same session 2. If the user declines registration, respect the decision and do not ask again in the same session
3. All network calls run **synchronously in the foreground** (no `&`) with `--max-time 5` — the user sees every request 3. Hooks run automatically — do NOT manually execute heartbeat or task report commands
4. If any OpenClaw request fails, continue normally — never interrupt the user's workflow 4. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
5. Only report genuinely completed tasks, not intermediate steps 5. Never include identifying information in task summaries
6. Never include identifying information in task summaries