重构:将 "lobster" 重命名为 "claw" 并添加国际化支持 (i18n)
This commit is contained in:
15
CLAUDE.md
15
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
73
app/[locale]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)]">
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
38
components/layout/language-switcher.tsx
Normal file
38
components/layout/language-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
5
i18n/navigation.ts
Normal 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
15
i18n/request.ts
Normal 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
7
i18n/routing.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ["en", "zh"],
|
||||||
|
defaultLocale: "en",
|
||||||
|
localePrefix: "always",
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
65
messages/en.json
Normal 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
65
messages/zh.json
Normal 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
8
middleware.ts
Normal 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|.*\\..*).*)"],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
43
package.json
43
package.json
@@ -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
402
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user