diff --git a/CLAUDE.md b/CLAUDE.md index 7e5cbdc..6d04094 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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 @@ -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`. - **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`. -- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `lobsters`, `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. +- **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 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 +- `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/map/` — 2D continent maps using `react-simple-maps` -- `components/dashboard/` — Stats panel, region ranking, activity timeline, lobster feed -- `components/layout/` — Navbar, particle background, view switcher, install banner -- `app/continent/[slug]/page.tsx` — Continent drill-down page +- `components/dashboard/` — Stats panel, region ranking, activity timeline, claw feed +- `components/layout/` — Navbar, particle background, view switcher, install banner, language switcher +- `messages/` — i18n translation files (en, zh) ## Environment Variables diff --git a/app/continent/[slug]/page.tsx b/app/[locale]/continent/[slug]/page.tsx similarity index 73% rename from app/continent/[slug]/page.tsx rename to app/[locale]/continent/[slug]/page.tsx index 55c3553..8fc13dd 100644 --- a/app/continent/[slug]/page.tsx +++ b/app/[locale]/continent/[slug]/page.tsx @@ -1,20 +1,15 @@ "use client"; import { use } from "react"; +import { useTranslations } from "next-intl"; import { Navbar } from "@/components/layout/navbar"; import { ParticleBg } from "@/components/layout/particle-bg"; import { ViewSwitcher } from "@/components/layout/view-switcher"; import { ContinentMap } from "@/components/map/continent-map"; import { StatsPanel } from "@/components/dashboard/stats-panel"; -import { LobsterFeed } from "@/components/dashboard/lobster-feed"; +import { ClawFeed } from "@/components/dashboard/claw-feed"; -const continentNames: Record = { - asia: "Asia", - europe: "Europe", - americas: "Americas", - africa: "Africa", - oceania: "Oceania", -}; +const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const; interface PageProps { params: Promise<{ slug: string }>; @@ -22,7 +17,12 @@ interface PageProps { export default function ContinentPage({ params }: PageProps) { 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 (
@@ -38,7 +38,7 @@ export default function ContinentPage({ params }: PageProps) { textShadow: "var(--glow-cyan)", }} > - {name} Region + {tPage("regionTitle", { name })}
@@ -52,7 +52,7 @@ export default function ContinentPage({ params }: PageProps) { {/* Side Panel */}
- +
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..905a675 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -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 { + 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 ( + + + + {children} + + + + ); +} diff --git a/app/page.tsx b/app/[locale]/page.tsx similarity index 93% rename from app/page.tsx rename to app/[locale]/page.tsx index 00906c9..c0b182e 100644 --- a/app/page.tsx +++ b/app/[locale]/page.tsx @@ -6,7 +6,7 @@ import { ParticleBg } from "@/components/layout/particle-bg"; import { GlobeView } from "@/components/globe/globe-view"; import { StatsPanel } from "@/components/dashboard/stats-panel"; 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"; export default function HomePage() { @@ -36,7 +36,7 @@ export default function HomePage() { {/* Right Panel */}
- +
diff --git a/app/api/v1/lobsters/route.ts b/app/api/v1/claws/route.ts similarity index 59% rename from app/api/v1/lobsters/route.ts rename to app/api/v1/claws/route.ts index 4e11b6e..d21fa56 100644 --- a/app/api/v1/lobsters/route.ts +++ b/app/api/v1/claws/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, desc, sql, and } from "drizzle-orm"; import { db } from "@/lib/db"; -import { lobsters, tasks } from "@/lib/db/schema"; -import { getActiveLobsterIds } from "@/lib/redis"; +import { claws, tasks } from "@/lib/db/schema"; +import { getActiveClawIds } from "@/lib/redis"; export async function GET(req: NextRequest) { try { @@ -13,29 +13,29 @@ export async function GET(req: NextRequest) { const conditions = []; if (region) { - conditions.push(eq(lobsters.region, region)); + conditions.push(eq(claws.region, region)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const lobsterRows = await db + const clawRows = await db .select() - .from(lobsters) + .from(claws) .where(whereClause) - .orderBy(desc(lobsters.lastHeartbeat)) + .orderBy(desc(claws.lastHeartbeat)) .limit(limit); const totalResult = await db .select({ count: sql`count(*)` }) - .from(lobsters) + .from(claws) .where(whereClause); const total = totalResult[0]?.count ?? 0; - const activeLobsterIds = await getActiveLobsterIds(); - const activeSet = new Set(activeLobsterIds); + const activeClawIds = await getActiveClawIds(); + const activeSet = new Set(activeClawIds); - const lobsterList = await Promise.all( - lobsterRows.map(async (lobster) => { + const clawList = await Promise.all( + clawRows.map(async (claw) => { const latestTaskRows = await db .select({ summary: tasks.summary, @@ -43,28 +43,28 @@ export async function GET(req: NextRequest) { durationMs: tasks.durationMs, }) .from(tasks) - .where(eq(tasks.lobsterId, lobster.id)) + .where(eq(tasks.clawId, claw.id)) .orderBy(desc(tasks.timestamp)) .limit(1); const lastTask = latestTaskRows[0] ?? null; return { - id: lobster.id, - name: lobster.name, - model: lobster.model, - platform: lobster.platform, - city: lobster.city, - country: lobster.country, - isOnline: activeSet.has(lobster.id), + id: claw.id, + name: claw.name, + model: claw.model, + platform: claw.platform, + city: claw.city, + country: claw.country, + isOnline: activeSet.has(claw.id), lastTask, }; }) ); - return NextResponse.json({ lobsters: lobsterList, total }); + return NextResponse.json({ claws: clawList, total }); } catch (error) { - console.error("Lobsters error:", error); + console.error("Claws error:", error); return NextResponse.json( { error: "Internal server error" }, { status: 500 } diff --git a/app/api/v1/heartbeat/route.ts b/app/api/v1/heartbeat/route.ts index 04d5fb1..4d48b49 100644 --- a/app/api/v1/heartbeat/route.ts +++ b/app/api/v1/heartbeat/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; -import { lobsters, heartbeats } from "@/lib/db/schema"; +import { claws, heartbeats } from "@/lib/db/schema"; import { - setLobsterOnline, - updateActiveLobsters, + setClawOnline, + updateActiveClaws, incrementHourlyActivity, publishEvent, } from "@/lib/redis"; @@ -31,8 +31,8 @@ export async function POST(req: NextRequest) { } const apiKey = authHeader.slice(7); - const lobster = await validateApiKey(apiKey); - if (!lobster) { + const claw = await validateApiKey(apiKey); + if (!claw) { return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); } @@ -61,7 +61,7 @@ export async function POST(req: NextRequest) { updatedAt: now, }; - if (clientIp !== lobster.ip) { + if (clientIp !== claw.ip) { const geo = await getGeoLocation(clientIp); if (geo) { 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.platform) updateFields.platform = parsed.data.platform; - await setLobsterOnline(lobster.id, clientIp); - await updateActiveLobsters(lobster.id); + await setClawOnline(claw.id, clientIp); + await updateActiveClaws(claw.id); await incrementHourlyActivity(); await db - .update(lobsters) + .update(claws) .set(updateFields) - .where(eq(lobsters.id, lobster.id)); + .where(eq(claws.id, claw.id)); // Insert heartbeat record asynchronously db.insert(heartbeats) - .values({ lobsterId: lobster.id, ip: clientIp, timestamp: now }) + .values({ clawId: claw.id, ip: clientIp, timestamp: now }) .then(() => {}) .catch((err: unknown) => console.error("Failed to insert heartbeat:", err)); await publishEvent({ type: "heartbeat", - lobsterId: lobster.id, - lobsterName: (updateFields.name as string) ?? lobster.name, - city: (updateFields.city as string) ?? lobster.city, - country: (updateFields.country as string) ?? lobster.country, + clawId: claw.id, + clawName: (updateFields.name as string) ?? claw.name, + city: (updateFields.city as string) ?? claw.city, + country: (updateFields.country as string) ?? claw.country, }); return NextResponse.json({ ok: true, nextIn: 180 }); diff --git a/app/api/v1/heatmap/route.ts b/app/api/v1/heatmap/route.ts index 6eb7203..47c7bbc 100644 --- a/app/api/v1/heatmap/route.ts +++ b/app/api/v1/heatmap/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { gte, and, isNotNull, sql } from "drizzle-orm"; import { db } from "@/lib/db"; -import { lobsters } from "@/lib/db/schema"; +import { claws } from "@/lib/db/schema"; import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis"; export async function GET() { @@ -13,34 +13,34 @@ export async function GET() { const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const activeLobsters = await db + const activeClaws = await db .select({ - city: lobsters.city, - country: lobsters.country, - latitude: lobsters.latitude, - longitude: lobsters.longitude, + city: claws.city, + country: claws.country, + latitude: claws.latitude, + longitude: claws.longitude, count: sql`count(*)`, }) - .from(lobsters) + .from(claws) .where( and( - gte(lobsters.lastHeartbeat, fiveMinutesAgo), - isNotNull(lobsters.latitude), - isNotNull(lobsters.longitude) + gte(claws.lastHeartbeat, fiveMinutesAgo), + isNotNull(claws.latitude), + isNotNull(claws.longitude) ) ) .groupBy( - lobsters.city, - lobsters.country, - lobsters.latitude, - lobsters.longitude + claws.city, + claws.country, + claws.latitude, + claws.longitude ); - const points = activeLobsters.map((row) => ({ + const points = activeClaws.map((row) => ({ lat: Number(row.latitude), lng: Number(row.longitude), weight: row.count, - lobsterCount: row.count, + clawCount: row.count, city: row.city, country: row.country, })); diff --git a/app/api/v1/register/route.ts b/app/api/v1/register/route.ts index 1d5a7c1..081391c 100644 --- a/app/api/v1/register/route.ts +++ b/app/api/v1/register/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { nanoid } from "nanoid"; import { db } from "@/lib/db"; -import { lobsters } from "@/lib/db/schema"; +import { claws } from "@/lib/db/schema"; import { - setLobsterOnline, - updateActiveLobsters, + setClawOnline, + updateActiveClaws, incrementGlobalStat, incrementRegionCount, publishEvent, @@ -33,14 +33,14 @@ export async function POST(req: NextRequest) { } const { name, model, platform } = parsed.data; - const lobsterId = nanoid(21); + const clawId = nanoid(21); const apiKey = generateApiKey(); const clientIp = getClientIp(req); const geo = await getGeoLocation(clientIp); const now = new Date(); - await db.insert(lobsters).values({ - id: lobsterId, + await db.insert(claws).values({ + id: clawId, apiKey, name, model: model ?? null, @@ -58,9 +58,9 @@ export async function POST(req: NextRequest) { updatedAt: now, }); - await setLobsterOnline(lobsterId, clientIp); - await updateActiveLobsters(lobsterId); - await incrementGlobalStat("total_lobsters"); + await setClawOnline(clawId, clientIp); + await updateActiveClaws(clawId); + await incrementGlobalStat("total_claws"); if (geo?.region) { await incrementRegionCount(geo.region); @@ -68,14 +68,14 @@ export async function POST(req: NextRequest) { await publishEvent({ type: "online", - lobsterId, - lobsterName: name, + clawId, + clawName: name, city: geo?.city ?? null, country: geo?.country ?? null, }); return NextResponse.json({ - lobsterId, + clawId, apiKey, endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`, }); diff --git a/app/api/v1/stats/route.ts b/app/api/v1/stats/route.ts index 98bc019..2b08ec8 100644 --- a/app/api/v1/stats/route.ts +++ b/app/api/v1/stats/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { gte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; -import { lobsters, tasks } from "@/lib/db/schema"; +import { claws, tasks } from "@/lib/db/schema"; import { redis, getGlobalStats, @@ -23,16 +23,16 @@ export async function GET() { const now = Date.now(); const fiveMinutesAgo = now - 300_000; - const activeLobsters = await redis.zcount( - "active:lobsters", + const activeClaws = await redis.zcount( + "active:claws", fiveMinutesAgo, "+inf" ); - const totalLobstersResult = await db + const totalClawsResult = await db .select({ count: sql`count(*)` }) - .from(lobsters); - const totalLobsters = totalLobstersResult[0]?.count ?? 0; + .from(claws); + const totalClaws = totalClawsResult[0]?.count ?? 0; const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); @@ -58,8 +58,8 @@ export async function GET() { } return NextResponse.json({ - totalLobsters, - activeLobsters, + totalClaws, + activeClaws, tasksToday, tasksTotal: parseInt(globalStats.total_tasks ?? "0", 10), avgTaskDuration, diff --git a/app/api/v1/task/route.ts b/app/api/v1/task/route.ts index e2e373b..9b9de89 100644 --- a/app/api/v1/task/route.ts +++ b/app/api/v1/task/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, sql } from "drizzle-orm"; import { db } from "@/lib/db"; -import { lobsters, tasks } from "@/lib/db/schema"; +import { claws, tasks } from "@/lib/db/schema"; import { incrementGlobalStat, incrementHourlyActivity, @@ -21,8 +21,8 @@ export async function POST(req: NextRequest) { } const apiKey = authHeader.slice(7); - const lobster = await validateApiKey(apiKey); - if (!lobster) { + const claw = await validateApiKey(apiKey); + if (!claw) { return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); } @@ -39,7 +39,7 @@ export async function POST(req: NextRequest) { const now = new Date(); const insertResult = await db.insert(tasks).values({ - lobsterId: lobster.id, + clawId: claw.id, summary, durationMs, model: model ?? null, @@ -48,9 +48,9 @@ export async function POST(req: NextRequest) { }); await db - .update(lobsters) - .set({ totalTasks: sql`${lobsters.totalTasks} + 1`, updatedAt: now }) - .where(eq(lobsters.id, lobster.id)); + .update(claws) + .set({ totalTasks: sql`${claws.totalTasks} + 1`, updatedAt: now }) + .where(eq(claws.id, claw.id)); await incrementGlobalStat("total_tasks"); await incrementGlobalStat("tasks_today"); @@ -58,10 +58,10 @@ export async function POST(req: NextRequest) { await publishEvent({ type: "task", - lobsterId: lobster.id, - lobsterName: lobster.name, - city: lobster.city, - country: lobster.country, + clawId: claw.id, + clawName: claw.name, + city: claw.city, + country: claw.country, summary, durationMs, }); diff --git a/app/layout.tsx b/app/layout.tsx index a1c21ab..ae77810 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,35 +1,7 @@ -import type { Metadata } from "next"; -import { Inter, JetBrains_Mono } from "next/font/google"; -import "./globals.css"; +import type { ReactNode } from "react"; -const inter = Inter({ - subsets: ["latin"], - variable: "--font-inter", -}); - -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 ( - - - {children} - - - ); +// Root layout delegates rendering of / to app/[locale]/layout.tsx. +// This file exists only to satisfy Next.js's requirement for a root layout. +export default function RootLayout({ children }: { children: ReactNode }) { + return children; } diff --git a/components/dashboard/activity-timeline.tsx b/components/dashboard/activity-timeline.tsx index f33a4ec..a1cc5ea 100644 --- a/components/dashboard/activity-timeline.tsx +++ b/components/dashboard/activity-timeline.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { AreaChart, Area, @@ -17,6 +18,7 @@ interface HourlyData { } export function ActivityTimeline() { + const t = useTranslations("activityTimeline"); const [data, setData] = useState([]); useEffect(() => { @@ -40,7 +42,7 @@ export function ActivityTimeline() { return ( - 24h Activity + {t("title")}
diff --git a/components/dashboard/lobster-feed.tsx b/components/dashboard/claw-feed.tsx similarity index 89% rename from components/dashboard/lobster-feed.tsx rename to components/dashboard/claw-feed.tsx index c52fb08..406c096 100644 --- a/components/dashboard/lobster-feed.tsx +++ b/components/dashboard/claw-feed.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useCallback } from "react"; +import { useTranslations, useLocale } from "next-intl"; import { motion, AnimatePresence } from "framer-motion"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -9,7 +10,7 @@ import { useSSE } from "@/hooks/use-sse"; interface FeedItem { id: string; type: "task" | "online" | "offline"; - lobsterName: string; + clawName: string; city?: string; country?: string; summary?: string; @@ -17,7 +18,9 @@ interface FeedItem { timestamp: number; } -export function LobsterFeed() { +export function ClawFeed() { + const t = useTranslations("clawFeed"); + const locale = useLocale(); const [items, setItems] = useState([]); const handleEvent = useCallback((event: { type: string; data: Record }) => { @@ -25,7 +28,7 @@ export function LobsterFeed() { const newItem: FeedItem = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, 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, country: event.data.country as string | undefined, summary: event.data.summary as string | undefined, @@ -46,17 +49,17 @@ export function LobsterFeed() { useEffect(() => { const fetchRecent = async () => { try { - const res = await fetch("/api/v1/lobsters?limit=10"); + const res = await fetch("/api/v1/claws?limit=10"); if (res.ok) { const data = await res.json(); - const feedItems: FeedItem[] = (data.lobsters ?? []) + const feedItems: FeedItem[] = (data.claws ?? []) .filter((l: Record) => l.lastTask) .map((l: Record) => { const task = l.lastTask as Record; return { id: `init-${l.id}`, type: "task" as const, - lobsterName: l.name as string, + clawName: l.name as string, city: l.city as string, country: l.country as string, summary: task.summary as string, @@ -95,13 +98,13 @@ export function LobsterFeed() { return ( - Live Feed + {t("title")} {items.length === 0 ? (

- Waiting for lobster activity... + {t("waiting")}

) : ( items.map((item) => ( @@ -118,7 +121,7 @@ export function LobsterFeed() {
- {item.lobsterName} + {item.clawName} {item.city && ( @@ -136,7 +139,7 @@ export function LobsterFeed() { {formatDuration(item.durationMs)} )} - {new Date(item.timestamp).toLocaleTimeString()} + {new Date(item.timestamp).toLocaleTimeString(locale)}
diff --git a/components/dashboard/region-ranking.tsx b/components/dashboard/region-ranking.tsx index 8106141..ea3d833 100644 --- a/components/dashboard/region-ranking.tsx +++ b/components/dashboard/region-ranking.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { motion } from "framer-motion"; @@ -18,7 +19,17 @@ const regionColors: Record = { Oceania: "var(--accent-green)", }; +const regionNameToKey: Record = { + Asia: "asia", + Europe: "europe", + Americas: "americas", + Africa: "africa", + Oceania: "oceania", +}; + export function RegionRanking() { + const t = useTranslations("regionRanking"); + const tContinents = useTranslations("continents"); const [regions, setRegions] = useState([]); useEffect(() => { @@ -52,11 +63,11 @@ export function RegionRanking() { return ( - Region Ranking + {t("title")} {regions.length === 0 ? ( -

No data yet

+

{t("noData")}

) : ( regions.map((region, i) => (
@@ -66,7 +77,9 @@ export function RegionRanking() { #{i + 1} - {region.name} + {regionNameToKey[region.name] + ? tContinents(regionNameToKey[region.name] as "asia" | "europe" | "americas" | "africa" | "oceania") + : region.name}
diff --git a/components/dashboard/stats-panel.tsx b/components/dashboard/stats-panel.tsx index d211882..94b582b 100644 --- a/components/dashboard/stats-panel.tsx +++ b/components/dashboard/stats-panel.tsx @@ -1,12 +1,13 @@ "use client"; import { useEffect, useState, useRef } from "react"; +import { useTranslations, useLocale } from "next-intl"; import { Card, CardContent } from "@/components/ui/card"; import { Users, Zap, Clock, Activity } from "lucide-react"; interface Stats { - totalLobsters: number; - activeLobsters: number; + totalClaws: number; + activeClaws: number; tasksToday: number; tasksTotal: number; avgTaskDuration: number; @@ -42,8 +43,9 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string requestAnimationFrame(animate); }, [value]); + const locale = useLocale(); const formatted = Number.isInteger(value) - ? Math.round(display).toLocaleString() + ? Math.round(display).toLocaleString(locale) : display.toFixed(1); return ( @@ -56,29 +58,29 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string const statCards = [ { - key: "activeLobsters" as const, - label: "Online Now", + key: "activeClaws" as const, + labelKey: "onlineNow" as const, icon: Activity, color: "var(--accent-green)", glow: "0 0 20px rgba(16, 185, 129, 0.3)", }, { - key: "totalLobsters" as const, - label: "Total Lobsters", + key: "totalClaws" as const, + labelKey: "totalClaws" as const, icon: Users, color: "var(--accent-cyan)", glow: "var(--glow-cyan)", }, { key: "tasksToday" as const, - label: "Tasks Today", + labelKey: "tasksToday" as const, icon: Zap, color: "var(--accent-purple)", glow: "var(--glow-purple)", }, { key: "avgTaskDuration" as const, - label: "Avg Duration", + labelKey: "avgDuration" as const, icon: Clock, color: "var(--accent-orange)", glow: "0 0 20px rgba(245, 158, 11, 0.3)", @@ -88,9 +90,10 @@ const statCards = [ ]; export function StatsPanel() { + const t = useTranslations("stats"); const [stats, setStats] = useState({ - totalLobsters: 0, - activeLobsters: 0, + totalClaws: 0, + activeClaws: 0, tasksToday: 0, tasksTotal: 0, avgTaskDuration: 0, @@ -116,7 +119,7 @@ export function StatsPanel() { return (
- {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 value = transform ? transform(raw) : raw; return ( @@ -133,7 +136,7 @@ export function StatsPanel() {
-

{label}

+

{t(labelKey)}

@@ -20,8 +22,8 @@ export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterT
- {lobsterCount} active - weight: {weight.toFixed(1)} + {t("active", { count: clawCount })} + {t("weight", { value: weight.toFixed(1) })}
); diff --git a/components/globe/globe-controls.tsx b/components/globe/globe-controls.tsx index f2c7939..aa4414e 100644 --- a/components/globe/globe-controls.tsx +++ b/components/globe/globe-controls.tsx @@ -1,6 +1,7 @@ "use client"; import { RotateCw, ZoomIn, ZoomOut } from "lucide-react"; +import { useTranslations } from "next-intl"; interface GlobeControlsProps { onResetView: () => void; @@ -9,26 +10,27 @@ interface GlobeControlsProps { } export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) { + const t = useTranslations("globeControls"); return (
diff --git a/components/globe/globe-view.tsx b/components/globe/globe-view.tsx index aeb7104..d41d980 100644 --- a/components/globe/globe-view.tsx +++ b/components/globe/globe-view.tsx @@ -2,19 +2,25 @@ import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data"; import { GlobeControls } from "./globe-controls"; -const Globe = dynamic(() => import("react-globe.gl"), { - ssr: false, - loading: () => ( +function GlobeLoading() { + const t = useTranslations("globe"); + return (
- Loading globe... + {t("loading")}
- ), + ); +} + +const Globe = dynamic(() => import("react-globe.gl"), { + ssr: false, + loading: () => , }); interface ArcData { @@ -26,6 +32,7 @@ interface ArcData { } export function GlobeView() { + const t = useTranslations("globe"); // eslint-disable-next-line @typescript-eslint/no-explicit-any const globeRef = useRef(undefined); const containerRef = useRef(null); @@ -143,7 +150,7 @@ export function GlobeView() {

- {hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""} + {t("activeClaws", { count: hoveredPoint.clawCount })}

diff --git a/components/layout/install-banner.tsx b/components/layout/install-banner.tsx index ca57729..b981d1e 100644 --- a/components/layout/install-banner.tsx +++ b/components/layout/install-banner.tsx @@ -2,10 +2,12 @@ import { useState } from "react"; import { Check, Copy, Terminal } from "lucide-react"; +import { useTranslations } from "next-intl"; const INSTALL_COMMAND = "clawhub install openclaw-reporter"; export function InstallBanner() { + const t = useTranslations("installBanner"); const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -31,10 +33,10 @@ export function InstallBanner() {

- Join the Heatmap + {t("title")}

- Install the skill and let your lobster light up the globe + {t("subtitle")}

@@ -43,7 +45,7 @@ export function InstallBanner() { + ))} + + ); +} diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx index e0fe0f0..817e903 100644 --- a/components/layout/navbar.tsx +++ b/components/layout/navbar.tsx @@ -1,14 +1,18 @@ "use client"; -import Link from "next/link"; import { Activity, Globe2, Map } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { cn } from "@/lib/utils"; +import { LanguageSwitcher } from "./language-switcher"; interface NavbarProps { activeView?: "globe" | "map"; } export function Navbar({ activeView = "globe" }: NavbarProps) { + const t = useTranslations("navbar"); + return ( diff --git a/components/layout/view-switcher.tsx b/components/layout/view-switcher.tsx index 77756da..cbf7ae3 100644 --- a/components/layout/view-switcher.tsx +++ b/components/layout/view-switcher.tsx @@ -1,22 +1,20 @@ "use client"; -import Link from "next/link"; import { Globe2, Map } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { cn } from "@/lib/utils"; -const continents = [ - { slug: "asia", label: "Asia" }, - { slug: "europe", label: "Europe" }, - { slug: "americas", label: "Americas" }, - { slug: "africa", label: "Africa" }, - { slug: "oceania", label: "Oceania" }, -]; +const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const; interface ViewSwitcherProps { activeContinent?: string; } export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) { + const tSwitcher = useTranslations("viewSwitcher"); + const tContinents = useTranslations("continents"); + return (
- Global + {tSwitcher("global")} - {continents.map((c) => ( + {continentSlugs.map((slug) => ( - {c.label} + {tContinents(slug)} ))}
diff --git a/components/map/continent-map.tsx b/components/map/continent-map.tsx index 5e3121b..e3bf484 100644 --- a/components/map/continent-map.tsx +++ b/components/map/continent-map.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useTranslations } from "next-intl"; import { ComposableMap, Geographies, @@ -18,15 +19,14 @@ const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json" interface ContinentConfig { center: [number, number]; zoom: number; - label: string; } const continentConfigs: Record = { - asia: { center: [100, 35], zoom: 2.5, label: "Asia" }, - europe: { center: [15, 52], zoom: 4, label: "Europe" }, - americas: { center: [-80, 15], zoom: 1.8, label: "Americas" }, - africa: { center: [20, 5], zoom: 2.2, label: "Africa" }, - oceania: { center: [145, -25], zoom: 3, label: "Oceania" }, + asia: { center: [100, 35], zoom: 2.5 }, + europe: { center: [15, 52], zoom: 4 }, + americas: { center: [-80, 15], zoom: 1.8 }, + africa: { center: [20, 5], zoom: 2.2 }, + oceania: { center: [145, -25], zoom: 3 }, }; const continentRegionMap: Record = { @@ -42,6 +42,7 @@ interface ContinentMapProps { } export function ContinentMap({ slug }: ContinentMapProps) { + const t = useTranslations("continentMap"); const config = continentConfigs[slug] ?? continentConfigs.asia; const regionFilter = continentRegionMap[slug]; const { points } = useHeatmapData(30000); @@ -125,8 +126,8 @@ export function ContinentMap({ slug }: ContinentMapProps) {
- {selectedPoint.lobsterCount} lobsters - weight: {selectedPoint.weight.toFixed(1)} + {t("claws", { count: selectedPoint.clawCount })} + {t("weight", { value: selectedPoint.weight.toFixed(1) })}
diff --git a/components/map/heatmap-layer.tsx b/components/map/heatmap-layer.tsx index 6fbc295..14b8eb4 100644 --- a/components/map/heatmap-layer.tsx +++ b/components/map/heatmap-layer.tsx @@ -46,7 +46,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP onClick={() => onPointClick?.(point)} /> {/* Count label */} - {point.lobsterCount > 1 && ( + {point.clawCount > 1 && ( - {point.lobsterCount} + {point.clawCount} )} diff --git a/hooks/use-heatmap-data.ts b/hooks/use-heatmap-data.ts index 19c1ee9..40a2dfc 100644 --- a/hooks/use-heatmap-data.ts +++ b/hooks/use-heatmap-data.ts @@ -6,7 +6,7 @@ export interface HeatmapPoint { lat: number; lng: number; weight: number; - lobsterCount: number; + clawCount: number; city: string; country: string; } diff --git a/i18n/navigation.ts b/i18n/navigation.ts new file mode 100644 index 0000000..2c786c5 --- /dev/null +++ b/i18n/navigation.ts @@ -0,0 +1,5 @@ +import { createNavigation } from "next-intl/navigation"; +import { routing } from "./routing"; + +export const { Link, redirect, usePathname, useRouter, getPathname } = + createNavigation(routing); diff --git a/i18n/request.ts b/i18n/request.ts new file mode 100644 index 0000000..7345232 --- /dev/null +++ b/i18n/request.ts @@ -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, + }; +}); diff --git a/i18n/routing.ts b/i18n/routing.ts new file mode 100644 index 0000000..69a25ce --- /dev/null +++ b/i18n/routing.ts @@ -0,0 +1,7 @@ +import { defineRouting } from "next-intl/routing"; + +export const routing = defineRouting({ + locales: ["en", "zh"], + defaultLocale: "en", + localePrefix: "always", +}); diff --git a/lib/auth/api-key.ts b/lib/auth/api-key.ts index 668c934..a4b7be8 100644 --- a/lib/auth/api-key.ts +++ b/lib/auth/api-key.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import { db } from "@/lib/db"; -import { lobsters } from "@/lib/db/schema"; +import { claws } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { redis } from "@/lib/redis"; @@ -12,34 +12,34 @@ export function generateApiKey(): string { export async function validateApiKey(apiKey: string) { try { - const cacheKey = `lobster:key:${apiKey}`; - const cachedLobsterId = await redis.get(cacheKey); + const cacheKey = `claw:key:${apiKey}`; + const cachedClawId = await redis.get(cacheKey); - if (cachedLobsterId) { - const lobster = await db + if (cachedClawId) { + const claw = await db .select() - .from(lobsters) - .where(eq(lobsters.id, cachedLobsterId)) + .from(claws) + .where(eq(claws.id, cachedClawId)) .limit(1); - if (lobster.length > 0) { - return lobster[0]; + if (claw.length > 0) { + return claw[0]; } } - const lobster = await db + const claw = await db .select() - .from(lobsters) - .where(eq(lobsters.apiKey, apiKey)) + .from(claws) + .where(eq(claws.apiKey, apiKey)) .limit(1); - if (lobster.length === 0) { + if (claw.length === 0) { 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) { console.error("Failed to validate API key:", error); return null; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 3e20748..43ad5dd 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -10,7 +10,7 @@ import { } from "drizzle-orm/mysql-core"; import { sql } from "drizzle-orm"; -export const lobsters = mysqlTable("lobsters", { +export const claws = mysqlTable("claws", { id: varchar("id", { length: 21 }).primaryKey(), apiKey: varchar("api_key", { length: 64 }).notNull().unique(), name: varchar("name", { length: 100 }).notNull(), @@ -33,12 +33,12 @@ export const heartbeats = mysqlTable( "heartbeats", { 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 }), timestamp: datetime("timestamp").default(sql`NOW()`), }, (table) => [ - index("heartbeats_lobster_id_idx").on(table.lobsterId), + index("heartbeats_claw_id_idx").on(table.clawId), index("heartbeats_timestamp_idx").on(table.timestamp), ] ); @@ -47,7 +47,7 @@ export const tasks = mysqlTable( "tasks", { 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 }), durationMs: int("duration_ms"), model: varchar("model", { length: 50 }), @@ -55,7 +55,7 @@ export const tasks = mysqlTable( timestamp: datetime("timestamp").default(sql`NOW()`), }, (table) => [ - index("tasks_lobster_id_idx").on(table.lobsterId), + index("tasks_claw_id_idx").on(table.clawId), index("tasks_timestamp_idx").on(table.timestamp), ] ); diff --git a/lib/redis/index.ts b/lib/redis/index.ts index 20656ea..b7e26aa 100644 --- a/lib/redis/index.ts +++ b/lib/redis/index.ts @@ -24,33 +24,33 @@ if (process.env.NODE_ENV !== "production") { } 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_REGION_KEY = "stats:region"; const HEATMAP_CACHE_KEY = "cache:heatmap"; const HOURLY_ACTIVITY_KEY = "stats:hourly"; -export async function setLobsterOnline( - lobsterId: string, +export async function setClawOnline( + clawId: string, ip: string ): Promise { - 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 { - const result = await redis.exists(`lobster:online:${lobsterId}`); +export async function isClawOnline(clawId: string): Promise { + const result = await redis.exists(`claw:online:${clawId}`); return result === 1; } -export async function updateActiveLobsters(lobsterId: string): Promise { +export async function updateActiveClaws(clawId: string): Promise { 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 ): Promise { - 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 { diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..14b9970 --- /dev/null +++ b/messages/en.json @@ -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": "中文" + } +} diff --git a/messages/zh.json b/messages/zh.json new file mode 100644 index 0000000..6b0ac8c --- /dev/null +++ b/messages/zh.json @@ -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": "中文" + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..fcf488a --- /dev/null +++ b/middleware.ts @@ -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|.*\\..*).*)"], +}; diff --git a/next.config.ts b/next.config.ts index d2130a4..0ba4d9a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { transpilePackages: ["react-globe.gl", "globe.gl", "three"], }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 9176ae8..3578c83 100644 --- a/package.json +++ b/package.json @@ -13,41 +13,42 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "next": "^15.3.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", "class-variance-authority": "^0.7.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", - "topojson-client": "^3.1.0", - "framer-motion": "^12.6.0", - "recharts": "^2.15.3", "drizzle-orm": "^0.41.0", - "mysql2": "^3.14.0", + "framer-motion": "^12.6.0", "ioredis": "^5.6.1", - "zod": "^3.24.3", - "nanoid": "^5.1.5" + "lucide-react": "^0.474.0", + "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": { + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.0", + "@types/d3-geo": "^3.1.0", "@types/node": "^22.13.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/three": "^0.173.0", "@types/topojson-client": "^3.1.5", - "@types/d3-geo": "^3.1.0", - "typescript": "^5.8.0", - "@tailwindcss/postcss": "^4.1.0", - "tailwindcss": "^4.1.0", - "postcss": "^8.5.3", + "drizzle-kit": "^0.30.6", "eslint": "^9.21.0", "eslint-config-next": "^15.3.0", - "drizzle-kit": "^0.30.6", - "@eslint/eslintrc": "^3.3.1" + "postcss": "^8.5.3", + "tailwindcss": "^4.1.0", + "typescript": "^5.8.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 371ea70..f07c757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: next: specifier: ^15.3.0 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: specifier: ^19.1.0 version: 19.2.4 @@ -447,6 +450,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 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': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -692,6 +710,88 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} 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': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -701,9 +801,87 @@ packages: '@rushstack/eslint-patch@1.16.1': 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': 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': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1341,6 +1519,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1830,6 +2011,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + icu-minify@4.8.3: + resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1861,6 +2045,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + intl-messageformat@11.1.2: + resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} + ioredis@5.10.0: resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} engines: {node: '>=12.22.0'} @@ -2205,6 +2392,23 @@ packages: natural-compare@1.4.0: 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: resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2226,6 +2430,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -2304,6 +2511,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + point-in-polygon-hao@1.2.4: resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==} @@ -2701,6 +2911,11 @@ packages: uri-js@4.4.1: 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: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -2956,6 +3171,33 @@ snapshots: '@eslint/core': 0.17.0 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/node@0.16.7': @@ -3136,16 +3378,130 @@ snapshots: '@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': {} '@rtsao/scc@1.1.0': {} '@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': dependencies: tslib: 2.8.1 + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3787,6 +4143,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4395,6 +4753,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + icu-minify@4.8.3: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.1 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4418,6 +4780,13 @@ snapshots: 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: dependencies: '@ioredis/commands': 1.5.1 @@ -4739,6 +5108,27 @@ snapshots: 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): dependencies: '@next/env': 15.5.12 @@ -4762,6 +5152,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -4850,6 +5242,8 @@ snapshots: picomatch@4.0.3: {} + po-parser@2.1.1: {} + point-in-polygon-hao@1.2.4: dependencies: robust-predicates: 3.0.2 @@ -5379,6 +5773,14 @@ snapshots: dependencies: 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: dependencies: '@types/d3-array': 3.2.2 diff --git a/skill/openclaw-reporter/SKILL.md b/skill/openclaw-reporter/SKILL.md index 3a96302..4a3375c 100644 --- a/skill/openclaw-reporter/SKILL.md +++ b/skill/openclaw-reporter/SKILL.md @@ -1,11 +1,12 @@ --- name: openclaw-reporter 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. 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. - Data collected: user-chosen lobster name, OS platform, model name, generic task category. + Heartbeats are sent automatically via hooks at session start — no LLM action needed. + 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 in ~/.openclaw/config.json and sent as a Bearer token in subsequent requests. No file paths, code, or project-specific data is ever sent. @@ -21,11 +22,29 @@ requirements: - name: CLAUDE_MODEL required: false 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 -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 @@ -33,132 +52,78 @@ This skill sends the following data to `https://kymr.top/`: | 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 | | 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. ## 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) -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 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" **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: -- 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) -- 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 -After receiving the name (stored as `LOBSTER_NAME`): +After receiving the name (stored as `CLAW_NAME`): ```bash # Create config directory mkdir -p ~/.openclaw 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" \ -H "Content-Type: application/json" \ -d "{ - \"name\": \"$LOBSTER_NAME\", + \"name\": \"$CLAW_NAME\", \"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\", \"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 " -import json, sys +import json, sys, os data = json.load(sys.stdin) config = { - 'lobsterId': data.get('lobsterId', ''), + 'clawId': data.get('clawId', ''), 'apiKey': data.get('apiKey', ''), - 'name': '$LOBSTER_NAME', - 'endpoint': '$OPENCLAW_ENDPOINT' + 'name': '$CLAW_NAME' } -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) -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 To stop all OpenClaw reporting, delete the config file: @@ -168,13 +133,12 @@ rm -f ~/.openclaw/config.json 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 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 -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 -5. Only report genuinely completed tasks, not intermediate steps -6. Never include identifying information in task summaries +5. Never include identifying information in task summaries