Add globe drag interaction and visitor country flag banner
Globe can now be dragged left/right to rotate (mouse + touch), with auto-rotation resuming from the new position on release. Visitor API aggregates hits by country/region/city, displayed as a scrolling flag emoji banner at the bottom of the globe card with expandable region breakdowns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,12 +29,14 @@ export async function GET() {
|
|||||||
`SELECT ip_address, COUNT(*) as hits FROM visits WHERE visited_at >= datetime('now', '-7 days') GROUP BY ip_address`
|
`SELECT ip_address, COUNT(*) as hits FROM visits WHERE visited_at >= datetime('now', '-7 days') GROUP BY ip_address`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Geo-locate IPs and group by location
|
// Geo-locate IPs and group by location + country
|
||||||
const locationMap = new Map<string, { lat: number; lon: number; city: string; country: string; count: number }>();
|
const locationMap = new Map<string, { lat: number; lon: number; city: string; country: string; count: number }>();
|
||||||
|
const countryMap = new Map<string, { count: number; regions: Map<string, { count: number; cities: Map<string, number> }> }>();
|
||||||
|
|
||||||
for (const row of recentIps) {
|
for (const row of recentIps) {
|
||||||
const geo = geoip.lookup(row.ip_address);
|
const geo = geoip.lookup(row.ip_address);
|
||||||
if (geo && geo.ll) {
|
if (geo && geo.ll) {
|
||||||
|
// Location grouping for globe markers
|
||||||
const key = `${geo.ll[0]},${geo.ll[1]}`;
|
const key = `${geo.ll[0]},${geo.ll[1]}`;
|
||||||
const existing = locationMap.get(key);
|
const existing = locationMap.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -48,6 +50,27 @@ export async function GET() {
|
|||||||
count: row.hits,
|
count: row.hits,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Country aggregation
|
||||||
|
const countryCode = geo.country || 'XX';
|
||||||
|
const regionCode = geo.region || 'Unknown';
|
||||||
|
const cityName = geo.city || 'Unknown';
|
||||||
|
|
||||||
|
let country = countryMap.get(countryCode);
|
||||||
|
if (!country) {
|
||||||
|
country = { count: 0, regions: new Map() };
|
||||||
|
countryMap.set(countryCode, country);
|
||||||
|
}
|
||||||
|
country.count += row.hits;
|
||||||
|
|
||||||
|
let region = country.regions.get(regionCode);
|
||||||
|
if (!region) {
|
||||||
|
region = { count: 0, cities: new Map() };
|
||||||
|
country.regions.set(regionCode, region);
|
||||||
|
}
|
||||||
|
region.count += row.hits;
|
||||||
|
|
||||||
|
region.cities.set(cityName, (region.cities.get(cityName) || 0) + row.hits);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +81,25 @@ export async function GET() {
|
|||||||
country: loc.country,
|
country: loc.country,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const countries = Array.from(countryMap.entries())
|
||||||
|
.map(([code, data]) => ({
|
||||||
|
code,
|
||||||
|
count: data.count,
|
||||||
|
regions: Array.from(data.regions.entries())
|
||||||
|
.map(([region, rData]) => ({
|
||||||
|
region,
|
||||||
|
count: rData.count,
|
||||||
|
cities: Array.from(rData.cities.entries())
|
||||||
|
.map(([city, count]) => ({ city, count }))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
locations,
|
locations,
|
||||||
|
countries,
|
||||||
totalVisitors: total?.count || 0,
|
totalVisitors: total?.count || 0,
|
||||||
active24h: last24h?.count || 0,
|
active24h: last24h?.count || 0,
|
||||||
active7d: last7d?.count || 0,
|
active7d: last7d?.count || 0,
|
||||||
|
|||||||
@@ -1,17 +1,77 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import createGlobe from 'cobe';
|
import createGlobe from 'cobe';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountryChip({ country }: { country: CountryData }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-neutral-800/80 border border-neutral-700/50 hover:border-neutral-600 transition-colors text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{expanded && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-1 w-56 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-30 p-2 max-h-48 overflow-y-auto">
|
||||||
|
{country.regions.map((r) => (
|
||||||
|
<div key={r.region} className="mb-1.5 last:mb-0">
|
||||||
|
<div className="flex justify-between text-xs px-1">
|
||||||
|
<span className="text-neutral-300 font-medium">{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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GlobeCard() {
|
export function GlobeCard() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [visitorStats, setVisitorStats] = useState({
|
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[];
|
||||||
|
}>({
|
||||||
total: 0,
|
total: 0,
|
||||||
active24h: 0,
|
active24h: 0,
|
||||||
active7d: 0,
|
active7d: 0,
|
||||||
locations: [],
|
locations: [],
|
||||||
|
countries: [],
|
||||||
});
|
});
|
||||||
|
const [grabbing, setGrabbing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVisitors() {
|
async function fetchVisitors() {
|
||||||
@@ -23,6 +83,7 @@ export function GlobeCard() {
|
|||||||
active24h: data.active24h || 0,
|
active24h: data.active24h || 0,
|
||||||
active7d: data.active7d || 0,
|
active7d: data.active7d || 0,
|
||||||
locations: data.locations || [],
|
locations: data.locations || [],
|
||||||
|
countries: data.countries || [],
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load visitors', e);
|
console.error('Failed to load visitors', e);
|
||||||
@@ -34,8 +95,6 @@ export function GlobeCard() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let phi = 0;
|
|
||||||
|
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
const globe = createGlobe(canvasRef.current, {
|
const globe = createGlobe(canvasRef.current, {
|
||||||
@@ -53,8 +112,10 @@ export function GlobeCard() {
|
|||||||
glowColor: [0.1, 0.1, 0.2],
|
glowColor: [0.1, 0.1, 0.2],
|
||||||
markers: visitorStats.locations,
|
markers: visitorStats.locations,
|
||||||
onRender: (state) => {
|
onRender: (state) => {
|
||||||
state.phi = phi;
|
if (pointerInteracting.current === null) {
|
||||||
phi += 0.01;
|
phiRef.current += 0.005;
|
||||||
|
}
|
||||||
|
state.phi = phiRef.current + pointerInteractionMovement.current;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +124,27 @@ export function GlobeCard() {
|
|||||||
};
|
};
|
||||||
}, [visitorStats.locations]);
|
}, [visitorStats.locations]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||||
<div className="absolute top-6 left-6 z-10 pointer-events-none">
|
<div className="absolute top-6 left-6 z-10 pointer-events-none">
|
||||||
@@ -92,9 +174,29 @@ export function GlobeCard() {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center opacity-80 mt-10">
|
<div className="absolute inset-0 flex items-center justify-center opacity-80 mt-10">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={{ width: 600, height: 600, maxWidth: '100%', aspectRatio: 1 }}
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerOut={handlePointerUp}
|
||||||
|
style={{
|
||||||
|
width: 600,
|
||||||
|
height: 600,
|
||||||
|
maxWidth: '100%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
cursor: grabbing ? 'grabbing' : 'grab',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{visitorStats.countries.map((country) => (
|
||||||
|
<CountryChip key={country.code} country={country} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user