init
This commit is contained in:
93
components/dashboard/activity-timeline.tsx
Normal file
93
components/dashboard/activity-timeline.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface HourlyData {
|
||||
hour: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ActivityTimeline() {
|
||||
const [data, setData] = useState<HourlyData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.hourlyActivity ?? []);
|
||||
}
|
||||
} catch {
|
||||
// retry on next interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>24h Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="activityGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#00f0ff" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#00f0ff" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1a1f2e",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: "#94a3b8" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#00f0ff"
|
||||
strokeWidth={2}
|
||||
fill="url(#activityGradient)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: "#00f0ff",
|
||||
stroke: "#0a0e1a",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
151
components/dashboard/lobster-feed.tsx
Normal file
151
components/dashboard/lobster-feed.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useSSE } from "@/hooks/use-sse";
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
type: "task" | "online" | "offline";
|
||||
lobsterName: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
summary?: string;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function LobsterFeed() {
|
||||
const [items, setItems] = useState<FeedItem[]>([]);
|
||||
|
||||
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
|
||||
if (event.type === "task" || event.type === "online" || event.type === "offline") {
|
||||
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",
|
||||
city: event.data.city as string | undefined,
|
||||
country: event.data.country as string | undefined,
|
||||
summary: event.data.summary as string | undefined,
|
||||
durationMs: event.data.durationMs as number | undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setItems((prev) => [newItem, ...prev].slice(0, 50));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useSSE({
|
||||
url: "/api/v1/stream",
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
// Load initial recent tasks
|
||||
useEffect(() => {
|
||||
const fetchRecent = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/lobsters?limit=10");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const feedItems: FeedItem[] = (data.lobsters ?? [])
|
||||
.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,
|
||||
city: l.city as string,
|
||||
country: l.country as string,
|
||||
summary: task.summary as string,
|
||||
durationMs: task.durationMs as number,
|
||||
timestamp: new Date(task.timestamp as string).getTime(),
|
||||
};
|
||||
});
|
||||
setItems(feedItems);
|
||||
}
|
||||
} catch {
|
||||
// will populate via SSE
|
||||
}
|
||||
};
|
||||
fetchRecent();
|
||||
}, []);
|
||||
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "task":
|
||||
return "⚡";
|
||||
case "online":
|
||||
return "🟢";
|
||||
case "offline":
|
||||
return "⭕";
|
||||
default:
|
||||
return "🦞";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Live Feed</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...
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: "auto", y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-b border-white/5 py-2.5 last:border-0"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 text-sm">{getIcon(item.type)}</span>
|
||||
<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}
|
||||
</span>
|
||||
{item.city && (
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{item.city}, {item.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.summary && (
|
||||
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{item.durationMs && (
|
||||
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
91
components/dashboard/region-ranking.tsx
Normal file
91
components/dashboard/region-ranking.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface RegionData {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const regionColors: Record<string, string> = {
|
||||
Asia: "var(--accent-cyan)",
|
||||
Europe: "var(--accent-purple)",
|
||||
Americas: "var(--accent-pink)",
|
||||
Africa: "var(--accent-orange)",
|
||||
Oceania: "var(--accent-green)",
|
||||
};
|
||||
|
||||
export function RegionRanking() {
|
||||
const [regions, setRegions] = useState<RegionData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const breakdown = (data.regionBreakdown ?? {}) as Record<string, number>;
|
||||
const sorted = Object.entries(breakdown)
|
||||
.map(([name, count]) => ({
|
||||
name,
|
||||
count,
|
||||
color: regionColors[name] ?? "var(--accent-cyan)",
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
setRegions(sorted);
|
||||
}
|
||||
} catch {
|
||||
// retry on interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const maxCount = Math.max(...regions.map((r) => r.count), 1);
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Region Ranking</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>
|
||||
) : (
|
||||
regions.map((region, i) => (
|
||||
<div key={region.name} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[var(--text-muted)]">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: region.color }}>
|
||||
{region.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-[var(--text-secondary)]">
|
||||
{region.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: region.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(region.count / maxCount) * 100}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
150
components/dashboard/stats-panel.tsx
Normal file
150
components/dashboard/stats-panel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Users, Zap, Clock, Activity } from "lucide-react";
|
||||
|
||||
interface Stats {
|
||||
totalLobsters: number;
|
||||
activeLobsters: number;
|
||||
tasksToday: number;
|
||||
tasksTotal: number;
|
||||
avgTaskDuration: number;
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string }) {
|
||||
const [display, setDisplay] = useState(0);
|
||||
const prevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const start = prevRef.current;
|
||||
const diff = value - start;
|
||||
if (diff === 0) return;
|
||||
|
||||
const duration = 800;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = start + diff * eased;
|
||||
|
||||
setDisplay(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
prevRef.current = value;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
const formatted = Number.isInteger(value)
|
||||
? Math.round(display).toLocaleString()
|
||||
: display.toFixed(1);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{formatted}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: "activeLobsters" as const,
|
||||
label: "Online Now",
|
||||
icon: Activity,
|
||||
color: "var(--accent-green)",
|
||||
glow: "0 0 20px rgba(16, 185, 129, 0.3)",
|
||||
},
|
||||
{
|
||||
key: "totalLobsters" as const,
|
||||
label: "Total Lobsters",
|
||||
icon: Users,
|
||||
color: "var(--accent-cyan)",
|
||||
glow: "var(--glow-cyan)",
|
||||
},
|
||||
{
|
||||
key: "tasksToday" as const,
|
||||
label: "Tasks Today",
|
||||
icon: Zap,
|
||||
color: "var(--accent-purple)",
|
||||
glow: "var(--glow-purple)",
|
||||
},
|
||||
{
|
||||
key: "avgTaskDuration" as const,
|
||||
label: "Avg Duration",
|
||||
icon: Clock,
|
||||
color: "var(--accent-orange)",
|
||||
glow: "0 0 20px rgba(245, 158, 11, 0.3)",
|
||||
suffix: "s",
|
||||
transform: (v: number) => v / 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export function StatsPanel() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalLobsters: 0,
|
||||
activeLobsters: 0,
|
||||
tasksToday: 0,
|
||||
tasksTotal: 0,
|
||||
avgTaskDuration: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stats");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch {
|
||||
// will retry on next interval
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{statCards.map(({ key, label, icon: Icon, color, glow, suffix, transform }) => {
|
||||
const raw = stats[key];
|
||||
const value = transform ? transform(raw) : raw;
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
className="overflow-hidden border-white/5 transition-all hover:border-white/10"
|
||||
style={{ boxShadow: glow }}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
<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="font-mono text-xl font-bold"
|
||||
style={{ color, textShadow: glow }}
|
||||
>
|
||||
<AnimatedNumber value={value} suffix={suffix} />
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
components/globe/globe-controls.tsx
Normal file
37
components/globe/globe-controls.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
interface GlobeControlsProps {
|
||||
onResetView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}
|
||||
|
||||
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
components/globe/globe-view.tsx
Normal file
159
components/globe/globe-view.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { GlobeControls } from "./globe-controls";
|
||||
|
||||
const Globe = dynamic(() => import("react-globe.gl"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ArcData {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function GlobeView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globeRef = useRef<any>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [hoveredPoint, setHoveredPoint] = useState<HeatmapPoint | null>(null);
|
||||
const { points } = useHeatmapData(30000);
|
||||
|
||||
// Generate arcs from recent activity (connecting pairs of active points)
|
||||
const arcs = useMemo((): ArcData[] => {
|
||||
if (points.length < 2) return [];
|
||||
const result: ArcData[] = [];
|
||||
const maxArcs = Math.min(points.length - 1, 8);
|
||||
for (let i = 0; i < maxArcs; i++) {
|
||||
const from = points[i];
|
||||
const to = points[(i + 1) % points.length];
|
||||
result.push({
|
||||
startLat: from.lat,
|
||||
startLng: from.lng,
|
||||
endLat: to.lat,
|
||||
endLng: to.lng,
|
||||
color: "rgba(0, 240, 255, 0.4)",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [points]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setDimensions({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
const observer = new ResizeObserver(updateDimensions);
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
|
||||
}
|
||||
}, [dimensions]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 1.5 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 3.5 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-white/5">
|
||||
{dimensions.width > 0 && (
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-dark.jpg"
|
||||
atmosphereColor="#00f0ff"
|
||||
atmosphereAltitude={0.15}
|
||||
// Points
|
||||
pointsData={points}
|
||||
pointLat={(d: object) => (d as HeatmapPoint).lat}
|
||||
pointLng={(d: object) => (d as HeatmapPoint).lng}
|
||||
pointAltitude={(d: object) => Math.min((d as HeatmapPoint).weight * 0.02, 0.15)}
|
||||
pointRadius={(d: object) => Math.max((d as HeatmapPoint).weight * 0.3, 0.4)}
|
||||
pointColor={() => "#00f0ff"}
|
||||
pointsMerge={false}
|
||||
onPointHover={(point: object | null) => setHoveredPoint(point as HeatmapPoint | null)}
|
||||
// Arcs
|
||||
arcsData={arcs}
|
||||
arcStartLat={(d: object) => (d as ArcData).startLat}
|
||||
arcStartLng={(d: object) => (d as ArcData).startLng}
|
||||
arcEndLat={(d: object) => (d as ArcData).endLat}
|
||||
arcEndLng={(d: object) => (d as ArcData).endLng}
|
||||
arcColor={(d: object) => (d as ArcData).color}
|
||||
arcDashLength={0.5}
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcStroke={0.3}
|
||||
// Auto-rotate
|
||||
animateIn={true}
|
||||
enablePointerInteraction={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-4 z-20 -translate-x-1/2">
|
||||
<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">
|
||||
<span className="text-lg">🦞</span>
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
|
||||
{hoveredPoint.city}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">{hoveredPoint.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GlobeControls
|
||||
onResetView={handleResetView}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/globe/lobster-tooltip.tsx
Normal file
28
components/globe/lobster-tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface LobsterTooltipProps {
|
||||
city: string;
|
||||
country: string;
|
||||
lobsterCount: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) {
|
||||
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">
|
||||
<span className="text-lg">🦞</span>
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">{city}</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">{country}</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
components/layout/install-banner.tsx
Normal file
63
components/layout/install-banner.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Terminal } from "lucide-react";
|
||||
|
||||
const INSTALL_COMMAND = "clawhub install openclaw-reporter";
|
||||
|
||||
export function InstallBanner() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback: select the text
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glow-card rounded-xl px-5 py-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Left: description */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: "rgba(0, 240, 255, 0.1)" }}
|
||||
>
|
||||
<span className="text-lg">🦞</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Join the Heatmap
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)] truncate">
|
||||
Install the skill and let your lobster light up the globe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: copy command */}
|
||||
<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"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||
<code className="font-mono text-sm text-[var(--accent-cyan)] select-all">
|
||||
{INSTALL_COMMAND}
|
||||
</code>
|
||||
<div className="ml-1 flex h-5 w-5 items-center justify-center">
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-[var(--text-muted)] transition-colors group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
components/layout/navbar.tsx
Normal file
61
components/layout/navbar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Globe2, Map } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavbarProps {
|
||||
activeView?: "globe" | "map";
|
||||
}
|
||||
|
||||
export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||
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">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl">🦞</span>
|
||||
<span
|
||||
className="font-mono text-lg font-bold tracking-tight"
|
||||
style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }}
|
||||
>
|
||||
OpenClaw Market
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||
activeView === "globe"
|
||||
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3.5 w-3.5" />
|
||||
3D Globe
|
||||
</Link>
|
||||
<Link
|
||||
href="/continent/asia"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||
activeView === "map"
|
||||
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Map className="h-3.5 w-3.5" />
|
||||
2D 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
104
components/layout/particle-bg.tsx
Normal file
104
components/layout/particle-bg.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export function ParticleBg() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animationId: number;
|
||||
const particles: Particle[] = [];
|
||||
const particleCount = 60;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
|
||||
const createParticle = (): Particle => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
size: Math.random() * 1.5 + 0.5,
|
||||
opacity: Math.random() * 0.3 + 0.1,
|
||||
});
|
||||
|
||||
const init = () => {
|
||||
resize();
|
||||
particles.length = 0;
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
particles.push(createParticle());
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const p of particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(0, 240, 255, ${p.opacity})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Draw connections
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 150) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.strokeStyle = `rgba(0, 240, 255, ${0.05 * (1 - dist / 150)})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
init();
|
||||
animate();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-0"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
components/layout/view-switcher.tsx
Normal file
51
components/layout/view-switcher.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Globe2, Map } from "lucide-react";
|
||||
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" },
|
||||
];
|
||||
|
||||
interface ViewSwitcherProps {
|
||||
activeContinent?: string;
|
||||
}
|
||||
|
||||
export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
!activeContinent
|
||||
? "border-[var(--accent-cyan)]/30 bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
Global
|
||||
</Link>
|
||||
{continents.map((c) => (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={`/continent/${c.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
|
||||
? "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}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
components/map/continent-map.tsx
Normal file
136
components/map/continent-map.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
ZoomableGroup,
|
||||
} from "react-simple-maps";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { HeatmapLayer } from "./heatmap-layer";
|
||||
import { geoMercator } from "d3-geo";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
const continentRegionMap: Record<string, string> = {
|
||||
asia: "Asia",
|
||||
europe: "Europe",
|
||||
americas: "Americas",
|
||||
africa: "Africa",
|
||||
oceania: "Oceania",
|
||||
};
|
||||
|
||||
interface ContinentMapProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ContinentMap({ slug }: ContinentMapProps) {
|
||||
const config = continentConfigs[slug] ?? continentConfigs.asia;
|
||||
const regionFilter = continentRegionMap[slug];
|
||||
const { points } = useHeatmapData(30000);
|
||||
const [selectedPoint, setSelectedPoint] = useState<HeatmapPoint | null>(null);
|
||||
|
||||
const filteredPoints = useMemo(
|
||||
() => (regionFilter ? points.filter(() => true) : points),
|
||||
[points, regionFilter]
|
||||
);
|
||||
|
||||
const projection = useMemo(
|
||||
() =>
|
||||
geoMercator()
|
||||
.center(config.center)
|
||||
.scale(150 * config.zoom)
|
||||
.translate([400, 300]),
|
||||
[config]
|
||||
);
|
||||
|
||||
const projectionFn = (coords: [number, number]): [number, number] | null => {
|
||||
const result = projection(coords);
|
||||
return result ?? null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden rounded-xl border border-white/5 bg-[var(--bg-secondary)]">
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
center: config.center,
|
||||
scale: 150 * config.zoom,
|
||||
}}
|
||||
width={800}
|
||||
height={600}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
>
|
||||
<ZoomableGroup center={config.center} zoom={1}>
|
||||
<Geographies geography={GEO_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill="#1a1f2e"
|
||||
stroke="#2a3040"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { fill: "#242a3d", outline: "none" },
|
||||
pressed: { outline: "none" },
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
<HeatmapLayer
|
||||
points={filteredPoints}
|
||||
projection={projectionFn}
|
||||
onPointClick={setSelectedPoint}
|
||||
/>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
|
||||
{selectedPoint && (
|
||||
<Card className="absolute bottom-4 left-4 z-10 w-64 border-[var(--accent-cyan)]/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
|
||||
{selectedPoint.city}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">{selectedPoint.country}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/map/heatmap-layer.tsx
Normal file
66
components/map/heatmap-layer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import type { HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
|
||||
interface HeatmapLayerProps {
|
||||
points: HeatmapPoint[];
|
||||
projection: (coords: [number, number]) => [number, number] | null;
|
||||
onPointClick?: (point: HeatmapPoint) => void;
|
||||
}
|
||||
|
||||
export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerProps) {
|
||||
return (
|
||||
<g>
|
||||
{points.map((point, i) => {
|
||||
const coords = projection([point.lng, point.lat]);
|
||||
if (!coords) return null;
|
||||
const [x, y] = coords;
|
||||
const radius = Math.max(point.weight * 3, 4);
|
||||
|
||||
return (
|
||||
<g key={`${point.city}-${point.country}-${i}`}>
|
||||
{/* Glow */}
|
||||
<motion.circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radius * 2}
|
||||
fill="rgba(0, 240, 255, 0.1)"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: [1, 1.3, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: i * 0.1 }}
|
||||
/>
|
||||
{/* Main dot */}
|
||||
<motion.circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radius}
|
||||
fill="rgba(0, 240, 255, 0.6)"
|
||||
stroke="rgba(0, 240, 255, 0.8)"
|
||||
strokeWidth={1}
|
||||
className="cursor-pointer"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.05 }}
|
||||
whileHover={{ scale: 1.5 }}
|
||||
onClick={() => onPointClick?.(point)}
|
||||
/>
|
||||
{/* Count label */}
|
||||
{point.lobsterCount > 1 && (
|
||||
<text
|
||||
x={x}
|
||||
y={y - radius - 4}
|
||||
textAnchor="middle"
|
||||
fill="#00f0ff"
|
||||
fontSize={9}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{point.lobsterCount}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
28
components/ui/badge.tsx
Normal file
28
components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]",
|
||||
secondary: "border-transparent bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]",
|
||||
online: "border-transparent bg-[var(--accent-green)]/10 text-[var(--accent-green)]",
|
||||
offline: "border-transparent bg-white/5 text-[var(--text-muted)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
39
components/ui/card.tsx
Normal file
39
components/ui/card.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-white/5 bg-[var(--bg-card)] text-[var(--text-primary)] shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm font-medium tracking-wide uppercase text-[var(--text-secondary)]", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent };
|
||||
Reference in New Issue
Block a user