重构:将 "lobster" 重命名为 "claw" 并添加国际化支持 (i18n)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -17,6 +18,7 @@ interface HourlyData {
|
||||
}
|
||||
|
||||
export function ActivityTimeline() {
|
||||
const t = useTranslations("activityTimeline");
|
||||
const [data, setData] = useState<HourlyData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,7 +42,7 @@ export function ActivityTimeline() {
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>24h Activity</CardTitle>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[160px] w-full">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,7 +10,7 @@ import { useSSE } from "@/hooks/use-sse";
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
type: "task" | "online" | "offline";
|
||||
lobsterName: string;
|
||||
clawName: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
summary?: string;
|
||||
@@ -17,7 +18,9 @@ interface FeedItem {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function LobsterFeed() {
|
||||
export function ClawFeed() {
|
||||
const t = useTranslations("clawFeed");
|
||||
const locale = useLocale();
|
||||
const [items, setItems] = useState<FeedItem[]>([]);
|
||||
|
||||
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
|
||||
@@ -25,7 +28,7 @@ export function LobsterFeed() {
|
||||
const newItem: FeedItem = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: event.type as FeedItem["type"],
|
||||
lobsterName: (event.data.lobsterName as string) ?? "Unknown",
|
||||
clawName: (event.data.clawName as string) ?? "Unknown",
|
||||
city: event.data.city as string | undefined,
|
||||
country: event.data.country as string | undefined,
|
||||
summary: event.data.summary as string | undefined,
|
||||
@@ -46,17 +49,17 @@ export function LobsterFeed() {
|
||||
useEffect(() => {
|
||||
const fetchRecent = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/lobsters?limit=10");
|
||||
const res = await fetch("/api/v1/claws?limit=10");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const feedItems: FeedItem[] = (data.lobsters ?? [])
|
||||
const feedItems: FeedItem[] = (data.claws ?? [])
|
||||
.filter((l: Record<string, unknown>) => l.lastTask)
|
||||
.map((l: Record<string, unknown>) => {
|
||||
const task = l.lastTask as Record<string, unknown>;
|
||||
return {
|
||||
id: `init-${l.id}`,
|
||||
type: "task" as const,
|
||||
lobsterName: l.name as string,
|
||||
clawName: l.name as string,
|
||||
city: l.city as string,
|
||||
country: l.country as string,
|
||||
summary: task.summary as string,
|
||||
@@ -95,13 +98,13 @@ export function LobsterFeed() {
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
|
||||
<AnimatePresence initial={false}>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
|
||||
Waiting for lobster activity...
|
||||
{t("waiting")}
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
@@ -118,7 +121,7 @@ export function LobsterFeed() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
|
||||
{item.lobsterName}
|
||||
{item.clawName}
|
||||
</span>
|
||||
{item.city && (
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
@@ -136,7 +139,7 @@ export function LobsterFeed() {
|
||||
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
{new Date(item.timestamp).toLocaleTimeString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
@@ -18,7 +19,17 @@ const regionColors: Record<string, string> = {
|
||||
Oceania: "var(--accent-green)",
|
||||
};
|
||||
|
||||
const regionNameToKey: Record<string, string> = {
|
||||
Asia: "asia",
|
||||
Europe: "europe",
|
||||
Americas: "americas",
|
||||
Africa: "africa",
|
||||
Oceania: "oceania",
|
||||
};
|
||||
|
||||
export function RegionRanking() {
|
||||
const t = useTranslations("regionRanking");
|
||||
const tContinents = useTranslations("continents");
|
||||
const [regions, setRegions] = useState<RegionData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,11 +63,11 @@ export function RegionRanking() {
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Region Ranking</CardTitle>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-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) => (
|
||||
<div key={region.name} className="space-y-1">
|
||||
@@ -66,7 +77,9 @@ export function RegionRanking() {
|
||||
#{i + 1}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-[var(--text-secondary)]">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Users, Zap, Clock, Activity } from "lucide-react";
|
||||
|
||||
interface Stats {
|
||||
totalLobsters: number;
|
||||
activeLobsters: number;
|
||||
totalClaws: number;
|
||||
activeClaws: number;
|
||||
tasksToday: number;
|
||||
tasksTotal: number;
|
||||
avgTaskDuration: number;
|
||||
@@ -42,8 +43,9 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
const locale = useLocale();
|
||||
const formatted = Number.isInteger(value)
|
||||
? Math.round(display).toLocaleString()
|
||||
? Math.round(display).toLocaleString(locale)
|
||||
: display.toFixed(1);
|
||||
|
||||
return (
|
||||
@@ -56,29 +58,29 @@ function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: "activeLobsters" as const,
|
||||
label: "Online Now",
|
||||
key: "activeClaws" as const,
|
||||
labelKey: "onlineNow" as const,
|
||||
icon: Activity,
|
||||
color: "var(--accent-green)",
|
||||
glow: "0 0 20px rgba(16, 185, 129, 0.3)",
|
||||
},
|
||||
{
|
||||
key: "totalLobsters" as const,
|
||||
label: "Total Lobsters",
|
||||
key: "totalClaws" as const,
|
||||
labelKey: "totalClaws" as const,
|
||||
icon: Users,
|
||||
color: "var(--accent-cyan)",
|
||||
glow: "var(--glow-cyan)",
|
||||
},
|
||||
{
|
||||
key: "tasksToday" as const,
|
||||
label: "Tasks Today",
|
||||
labelKey: "tasksToday" as const,
|
||||
icon: Zap,
|
||||
color: "var(--accent-purple)",
|
||||
glow: "var(--glow-purple)",
|
||||
},
|
||||
{
|
||||
key: "avgTaskDuration" as const,
|
||||
label: "Avg Duration",
|
||||
labelKey: "avgDuration" as const,
|
||||
icon: Clock,
|
||||
color: "var(--accent-orange)",
|
||||
glow: "0 0 20px rgba(245, 158, 11, 0.3)",
|
||||
@@ -88,9 +90,10 @@ const statCards = [
|
||||
];
|
||||
|
||||
export function StatsPanel() {
|
||||
const t = useTranslations("stats");
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalLobsters: 0,
|
||||
activeLobsters: 0,
|
||||
totalClaws: 0,
|
||||
activeClaws: 0,
|
||||
tasksToday: 0,
|
||||
tasksTotal: 0,
|
||||
avgTaskDuration: 0,
|
||||
@@ -116,7 +119,7 @@ export function StatsPanel() {
|
||||
|
||||
return (
|
||||
<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 value = transform ? transform(raw) : raw;
|
||||
return (
|
||||
@@ -133,7 +136,7 @@ export function StatsPanel() {
|
||||
<Icon className="h-5 w-5" style={{ color }} />
|
||||
</div>
|
||||
<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
|
||||
className="font-mono text-xl font-bold"
|
||||
style={{ color, textShadow: glow }}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface LobsterTooltipProps {
|
||||
interface ClawTooltipProps {
|
||||
city: string;
|
||||
country: string;
|
||||
lobsterCount: number;
|
||||
clawCount: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) {
|
||||
export function ClawTooltip({ city, country, clawCount, weight }: ClawTooltipProps) {
|
||||
const t = useTranslations("continentMap");
|
||||
return (
|
||||
<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">
|
||||
@@ -20,8 +22,8 @@ export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterT
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="online">{lobsterCount} active</Badge>
|
||||
<Badge variant="secondary">weight: {weight.toFixed(1)}</Badge>
|
||||
<Badge variant="online">{t("active", { count: clawCount })}</Badge>
|
||||
<Badge variant="secondary">{t("weight", { value: weight.toFixed(1) })}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface GlobeControlsProps {
|
||||
onResetView: () => void;
|
||||
@@ -9,26 +10,27 @@ interface GlobeControlsProps {
|
||||
}
|
||||
|
||||
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
|
||||
const t = useTranslations("globeControls");
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
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)]"
|
||||
aria-label="Zoom in"
|
||||
aria-label={t("zoomIn")}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
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)]"
|
||||
aria-label="Zoom out"
|
||||
aria-label={t("zoomOut")}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
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)]"
|
||||
aria-label="Reset view"
|
||||
aria-label={t("resetView")}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { GlobeControls } from "./globe-controls";
|
||||
|
||||
const Globe = dynamic(() => import("react-globe.gl"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
function GlobeLoading() {
|
||||
const t = useTranslations("globe");
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<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" />
|
||||
<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>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const Globe = dynamic(() => import("react-globe.gl"), {
|
||||
ssr: false,
|
||||
loading: () => <GlobeLoading />,
|
||||
});
|
||||
|
||||
interface ArcData {
|
||||
@@ -26,6 +32,7 @@ interface ArcData {
|
||||
}
|
||||
|
||||
export function GlobeView() {
|
||||
const t = useTranslations("globe");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globeRef = useRef<any>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -143,7 +150,7 @@ export function GlobeView() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""}
|
||||
{t("activeClaws", { count: hoveredPoint.clawCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Terminal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const INSTALL_COMMAND = "clawhub install openclaw-reporter";
|
||||
|
||||
export function InstallBanner() {
|
||||
const t = useTranslations("installBanner");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
@@ -31,10 +33,10 @@ export function InstallBanner() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Join the Heatmap
|
||||
{t("title")}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)] truncate">
|
||||
Install the skill and let your lobster light up the globe
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +45,7 @@ export function InstallBanner() {
|
||||
<button
|
||||
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"
|
||||
title="Click to copy"
|
||||
title={t("copyTooltip")}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||
<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";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Globe2, Map } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
|
||||
interface NavbarProps {
|
||||
activeView?: "globe" | "map";
|
||||
}
|
||||
|
||||
export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
const t = useTranslations("navbar");
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -18,7 +22,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
className="font-mono text-lg font-bold tracking-tight"
|
||||
style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }}
|
||||
>
|
||||
OpenClaw Market
|
||||
{t("brand")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -33,7 +37,7 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3.5 w-3.5" />
|
||||
3D Globe
|
||||
{t("globe")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/continent/asia"
|
||||
@@ -45,15 +49,16 @@ export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
)}
|
||||
>
|
||||
<Map className="h-3.5 w-3.5" />
|
||||
2D Map
|
||||
{t("map")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Globe2, Map } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const continents = [
|
||||
{ slug: "asia", label: "Asia" },
|
||||
{ slug: "europe", label: "Europe" },
|
||||
{ slug: "americas", label: "Americas" },
|
||||
{ slug: "africa", label: "Africa" },
|
||||
{ slug: "oceania", label: "Oceania" },
|
||||
];
|
||||
const continentSlugs = ["asia", "europe", "americas", "africa", "oceania"] as const;
|
||||
|
||||
interface ViewSwitcherProps {
|
||||
activeContinent?: string;
|
||||
}
|
||||
|
||||
export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
|
||||
const tSwitcher = useTranslations("viewSwitcher");
|
||||
const tContinents = useTranslations("continents");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
@@ -29,21 +27,21 @@ export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
Global
|
||||
{tSwitcher("global")}
|
||||
</Link>
|
||||
{continents.map((c) => (
|
||||
{continentSlugs.map((slug) => (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={`/continent/${c.slug}`}
|
||||
key={slug}
|
||||
href={`/continent/${slug}`}
|
||||
className={cn(
|
||||
"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-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Map className="h-3 w-3" />
|
||||
{c.label}
|
||||
{tContinents(slug)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
@@ -18,15 +19,14 @@ const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
|
||||
interface ContinentConfig {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const continentConfigs: Record<string, ContinentConfig> = {
|
||||
asia: { center: [100, 35], zoom: 2.5, label: "Asia" },
|
||||
europe: { center: [15, 52], zoom: 4, label: "Europe" },
|
||||
americas: { center: [-80, 15], zoom: 1.8, label: "Americas" },
|
||||
africa: { center: [20, 5], zoom: 2.2, label: "Africa" },
|
||||
oceania: { center: [145, -25], zoom: 3, label: "Oceania" },
|
||||
asia: { center: [100, 35], zoom: 2.5 },
|
||||
europe: { center: [15, 52], zoom: 4 },
|
||||
americas: { center: [-80, 15], zoom: 1.8 },
|
||||
africa: { center: [20, 5], zoom: 2.2 },
|
||||
oceania: { center: [145, -25], zoom: 3 },
|
||||
};
|
||||
|
||||
const continentRegionMap: Record<string, string> = {
|
||||
@@ -42,6 +42,7 @@ interface ContinentMapProps {
|
||||
}
|
||||
|
||||
export function ContinentMap({ slug }: ContinentMapProps) {
|
||||
const t = useTranslations("continentMap");
|
||||
const config = continentConfigs[slug] ?? continentConfigs.asia;
|
||||
const regionFilter = continentRegionMap[slug];
|
||||
const { points } = useHeatmapData(30000);
|
||||
@@ -125,8 +126,8 @@ export function ContinentMap({ slug }: ContinentMapProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Badge variant="online">{selectedPoint.lobsterCount} lobsters</Badge>
|
||||
<Badge variant="secondary">weight: {selectedPoint.weight.toFixed(1)}</Badge>
|
||||
<Badge variant="online">{t("claws", { count: selectedPoint.clawCount })}</Badge>
|
||||
<Badge variant="secondary">{t("weight", { value: selectedPoint.weight.toFixed(1) })}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
|
||||
onClick={() => onPointClick?.(point)}
|
||||
/>
|
||||
{/* Count label */}
|
||||
{point.lobsterCount > 1 && (
|
||||
{point.clawCount > 1 && (
|
||||
<text
|
||||
x={x}
|
||||
y={y - radius - 4}
|
||||
@@ -55,7 +55,7 @@ export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerP
|
||||
fontSize={9}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{point.lobsterCount}
|
||||
{point.clawCount}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
Reference in New Issue
Block a user