2026-02-08 02:32:45 -05:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import createGlobe from 'cobe';
|
2026-02-09 02:39:30 -05:00
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
2026-02-08 02:32:45 -05:00
|
|
|
import { Globe } from 'lucide-react';
|
|
|
|
|
|
2026-02-09 02:39:30 -05:00
|
|
|
interface CountryData {
|
|
|
|
|
code: string;
|
|
|
|
|
count: number;
|
|
|
|
|
regions: {
|
|
|
|
|
region: string;
|
|
|
|
|
count: number;
|
|
|
|
|
cities: { city: string; count: number }[];
|
|
|
|
|
}[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countryCodeToFlag(code: string): string {
|
|
|
|
|
return [...code.toUpperCase()].map(c => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)).join('');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 02:46:41 -05:00
|
|
|
function CountryChip({ country, isSelected, onSelect }: { country: CountryData; isSelected: boolean; onSelect: (code: string | null) => void }) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onSelect(isSelected ? null : country.code)}
|
|
|
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg border transition-colors text-sm cursor-pointer flex-shrink-0 ${
|
|
|
|
|
isSelected
|
|
|
|
|
? 'bg-neutral-700/80 border-neutral-500'
|
|
|
|
|
: 'bg-neutral-800/80 border-neutral-700/50 hover:border-neutral-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-base leading-none">{countryCodeToFlag(country.code)}</span>
|
|
|
|
|
<span className="text-neutral-400 font-mono text-xs">{country.code}</span>
|
|
|
|
|
<span className="text-neutral-200 font-semibold text-xs">{country.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-09 02:39:30 -05:00
|
|
|
|
2026-02-09 02:46:41 -05:00
|
|
|
function CountryDetail({ country }: { country: CountryData }) {
|
2026-02-09 02:39:30 -05:00
|
|
|
return (
|
2026-02-09 02:46:41 -05:00
|
|
|
<div className="mb-2 bg-neutral-800/90 border border-neutral-700 rounded-lg p-2.5 max-h-36 overflow-y-auto">
|
|
|
|
|
<div className="flex items-center gap-2 mb-1.5 text-xs text-neutral-300 font-medium">
|
|
|
|
|
<span className="text-sm">{countryCodeToFlag(country.code)}</span>
|
|
|
|
|
<span>{country.code}</span>
|
|
|
|
|
<span className="text-neutral-500">{country.count} hits</span>
|
|
|
|
|
</div>
|
|
|
|
|
{country.regions.map((r) => (
|
|
|
|
|
<div key={r.region} className="mb-1 last:mb-0">
|
|
|
|
|
<div className="flex justify-between text-xs px-1">
|
|
|
|
|
<span className="text-neutral-300">{r.region}</span>
|
|
|
|
|
<span className="text-neutral-500">{r.count}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{r.cities.map((c) => (
|
|
|
|
|
<div key={c.city} className="flex justify-between text-xs px-1 pl-3">
|
|
|
|
|
<span className="text-neutral-500">{c.city}</span>
|
|
|
|
|
<span className="text-neutral-600">{c.count}</span>
|
2026-02-09 02:39:30 -05:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-02-09 02:46:41 -05:00
|
|
|
))}
|
2026-02-09 02:39:30 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:32:45 -05:00
|
|
|
export function GlobeCard() {
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
2026-02-09 02:39:30 -05:00
|
|
|
const pointerInteracting = useRef<number | null>(null);
|
|
|
|
|
const pointerInteractionMovement = useRef(0);
|
|
|
|
|
const phiRef = useRef(0);
|
|
|
|
|
const [visitorStats, setVisitorStats] = useState<{
|
|
|
|
|
total: number;
|
|
|
|
|
active24h: number;
|
|
|
|
|
active7d: number;
|
|
|
|
|
locations: any[];
|
|
|
|
|
countries: CountryData[];
|
|
|
|
|
}>({
|
2026-02-09 00:50:40 -05:00
|
|
|
total: 0,
|
|
|
|
|
active24h: 0,
|
|
|
|
|
active7d: 0,
|
|
|
|
|
locations: [],
|
2026-02-09 02:39:30 -05:00
|
|
|
countries: [],
|
2026-02-09 00:50:40 -05:00
|
|
|
});
|
2026-02-09 02:39:30 -05:00
|
|
|
const [grabbing, setGrabbing] = useState(false);
|
2026-02-09 02:46:41 -05:00
|
|
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
2026-02-08 03:03:53 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
async function fetchVisitors() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/visitors');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
setVisitorStats({
|
|
|
|
|
total: data.totalVisitors || 0,
|
|
|
|
|
active24h: data.active24h || 0,
|
2026-02-09 00:50:40 -05:00
|
|
|
active7d: data.active7d || 0,
|
|
|
|
|
locations: data.locations || [],
|
2026-02-09 02:39:30 -05:00
|
|
|
countries: data.countries || [],
|
2026-02-08 03:03:53 -05:00
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to load visitors', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fetchVisitors();
|
2026-02-09 00:50:40 -05:00
|
|
|
const interval = setInterval(fetchVisitors, 30000);
|
2026-02-08 03:03:53 -05:00
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, []);
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!canvasRef.current) return;
|
|
|
|
|
|
|
|
|
|
const globe = createGlobe(canvasRef.current, {
|
|
|
|
|
devicePixelRatio: 2,
|
|
|
|
|
width: 600 * 2,
|
|
|
|
|
height: 600 * 2,
|
|
|
|
|
phi: 0,
|
|
|
|
|
theta: 0,
|
|
|
|
|
dark: 1,
|
|
|
|
|
diffuse: 1.2,
|
|
|
|
|
mapSamples: 16000,
|
|
|
|
|
mapBrightness: 6,
|
|
|
|
|
baseColor: [0.3, 0.3, 0.3],
|
|
|
|
|
markerColor: [0.1, 0.8, 1],
|
|
|
|
|
glowColor: [0.1, 0.1, 0.2],
|
2026-02-08 03:03:53 -05:00
|
|
|
markers: visitorStats.locations,
|
2026-02-08 02:32:45 -05:00
|
|
|
onRender: (state) => {
|
2026-02-09 02:39:30 -05:00
|
|
|
if (pointerInteracting.current === null) {
|
|
|
|
|
phiRef.current += 0.005;
|
|
|
|
|
}
|
|
|
|
|
state.phi = phiRef.current + pointerInteractionMovement.current;
|
2026-02-08 02:32:45 -05:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
globe.destroy();
|
|
|
|
|
};
|
2026-02-08 03:03:53 -05:00
|
|
|
}, [visitorStats.locations]);
|
2026-02-08 02:32:45 -05:00
|
|
|
|
2026-02-09 02:39:30 -05:00
|
|
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
|
|
|
pointerInteracting.current = e.clientX;
|
|
|
|
|
setGrabbing(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
|
|
|
|
if (pointerInteracting.current !== null) {
|
|
|
|
|
const delta = e.clientX - pointerInteracting.current;
|
|
|
|
|
pointerInteractionMovement.current = delta / 200;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handlePointerUp = useCallback(() => {
|
|
|
|
|
if (pointerInteracting.current !== null) {
|
|
|
|
|
phiRef.current += pointerInteractionMovement.current;
|
|
|
|
|
pointerInteractionMovement.current = 0;
|
|
|
|
|
pointerInteracting.current = null;
|
|
|
|
|
}
|
|
|
|
|
setGrabbing(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-08 02:32:45 -05:00
|
|
|
return (
|
2026-02-09 17:51:06 -05:00
|
|
|
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
|
|
|
|
<div className="widget-drag-handle absolute top-6 left-6 right-6 z-10">
|
2026-02-08 02:32:45 -05:00
|
|
|
<div className="flex items-center gap-2 text-neutral-400">
|
|
|
|
|
<Globe size={18} />
|
2026-02-08 03:03:53 -05:00
|
|
|
<span className="text-sm font-medium">Visitor Map</span>
|
2026-02-08 02:32:45 -05:00
|
|
|
</div>
|
2026-02-09 00:50:40 -05:00
|
|
|
<div className="mt-4 space-y-3">
|
2026-02-08 03:03:53 -05:00
|
|
|
<div>
|
|
|
|
|
<span className="text-2xl font-bold text-white tracking-tight block">{visitorStats.active24h}</span>
|
|
|
|
|
<span className="text-xs text-neutral-500 font-mono">LAST 24H</span>
|
|
|
|
|
</div>
|
2026-02-09 00:50:40 -05:00
|
|
|
<div>
|
|
|
|
|
<span className="text-lg font-bold text-neutral-300 block">{visitorStats.active7d}</span>
|
|
|
|
|
<span className="text-xs text-neutral-500 font-mono">LAST 7D</span>
|
|
|
|
|
</div>
|
2026-02-08 03:03:53 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="absolute top-6 right-6 z-10 text-right pointer-events-none">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xl font-bold text-neutral-300 block">{visitorStats.total}</span>
|
2026-02-09 00:50:40 -05:00
|
|
|
<span className="text-xs text-neutral-500 font-mono">ALL TIME</span>
|
2026-02-08 02:32:45 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center opacity-80 mt-10">
|
|
|
|
|
<canvas
|
|
|
|
|
ref={canvasRef}
|
2026-02-09 02:39:30 -05:00
|
|
|
onPointerDown={handlePointerDown}
|
|
|
|
|
onPointerMove={handlePointerMove}
|
|
|
|
|
onPointerUp={handlePointerUp}
|
|
|
|
|
onPointerOut={handlePointerUp}
|
|
|
|
|
style={{
|
|
|
|
|
width: 600,
|
|
|
|
|
height: 600,
|
|
|
|
|
maxWidth: '100%',
|
|
|
|
|
aspectRatio: 1,
|
|
|
|
|
cursor: grabbing ? 'grabbing' : 'grab',
|
|
|
|
|
}}
|
2026-02-08 02:32:45 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-09 02:39:30 -05:00
|
|
|
|
|
|
|
|
{visitorStats.countries.length > 0 && (
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 z-10 px-4 pb-3 pt-6 bg-gradient-to-t from-neutral-900 via-neutral-900/90 to-transparent">
|
2026-02-09 02:46:41 -05:00
|
|
|
{selectedCountry && (() => {
|
|
|
|
|
const country = visitorStats.countries.find(c => c.code === selectedCountry);
|
|
|
|
|
return country ? <CountryDetail country={country} /> : null;
|
|
|
|
|
})()}
|
2026-02-09 02:39:30 -05:00
|
|
|
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
|
|
|
|
|
{visitorStats.countries.map((country) => (
|
2026-02-09 02:46:41 -05:00
|
|
|
<CountryChip
|
|
|
|
|
key={country.code}
|
|
|
|
|
country={country}
|
|
|
|
|
isSelected={selectedCountry === country.code}
|
|
|
|
|
onSelect={setSelectedCountry}
|
|
|
|
|
/>
|
2026-02-09 02:39:30 -05:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-08 02:32:45 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|