From 9d8d34abb383243fb677ba843f8a523187b0c6f1 Mon Sep 17 00:00:00 2001 From: Shivam Patel Date: Mon, 9 Feb 2026 02:39:30 -0500 Subject: [PATCH] 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 --- app/api/visitors/route.ts | 42 ++++++++++- components/widgets/GlobeCard.tsx | 116 +++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/app/api/visitors/route.ts b/app/api/visitors/route.ts index 38d5ef7..1d785c1 100644 --- a/app/api/visitors/route.ts +++ b/app/api/visitors/route.ts @@ -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` ); - // Geo-locate IPs and group by location + // Geo-locate IPs and group by location + country const locationMap = new Map(); + const countryMap = new Map }> }>(); for (const row of recentIps) { const geo = geoip.lookup(row.ip_address); if (geo && geo.ll) { + // Location grouping for globe markers const key = `${geo.ll[0]},${geo.ll[1]}`; const existing = locationMap.get(key); if (existing) { @@ -48,6 +50,27 @@ export async function GET() { 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, })); + 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({ locations, + countries, totalVisitors: total?.count || 0, active24h: last24h?.count || 0, active7d: last7d?.count || 0, diff --git a/components/widgets/GlobeCard.tsx b/components/widgets/GlobeCard.tsx index 9abf475..c27fa1f 100644 --- a/components/widgets/GlobeCard.tsx +++ b/components/widgets/GlobeCard.tsx @@ -1,17 +1,77 @@ 'use client'; import createGlobe from 'cobe'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from '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 ( +
+ + {expanded && ( +
+ {country.regions.map((r) => ( +
+
+ {r.region} + {r.count} +
+ {r.cities.map((c) => ( +
+ {c.city} + {c.count} +
+ ))} +
+ ))} +
+ )} +
+ ); +} + export function GlobeCard() { const canvasRef = useRef(null); - const [visitorStats, setVisitorStats] = useState({ + const pointerInteracting = useRef(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, active24h: 0, active7d: 0, locations: [], + countries: [], }); + const [grabbing, setGrabbing] = useState(false); useEffect(() => { async function fetchVisitors() { @@ -23,6 +83,7 @@ export function GlobeCard() { active24h: data.active24h || 0, active7d: data.active7d || 0, locations: data.locations || [], + countries: data.countries || [], }); } catch (e) { console.error('Failed to load visitors', e); @@ -34,8 +95,6 @@ export function GlobeCard() { }, []); useEffect(() => { - let phi = 0; - if (!canvasRef.current) return; const globe = createGlobe(canvasRef.current, { @@ -53,8 +112,10 @@ export function GlobeCard() { glowColor: [0.1, 0.1, 0.2], markers: visitorStats.locations, onRender: (state) => { - state.phi = phi; - phi += 0.01; + if (pointerInteracting.current === null) { + phiRef.current += 0.005; + } + state.phi = phiRef.current + pointerInteractionMovement.current; }, }); @@ -63,6 +124,27 @@ export function GlobeCard() { }; }, [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 (
@@ -92,9 +174,29 @@ export function GlobeCard() {
+ + {visitorStats.countries.length > 0 && ( +
+
+ {visitorStats.countries.map((country) => ( + + ))} +
+
+ )}
); }