mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat: center globe on viewer'S location (#1715)
This commit is contained in:
parent
f38fde7d05
commit
165d9a0786
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useViewerLocation } from '@/hooks/use-viewer-location';
|
||||
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -33,6 +34,7 @@ function GlobeErrorComponent(props: { error: Error }) {
|
||||
function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const data = (adminApp as any)[hexclaveAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
const viewerLocation = useViewerLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -42,6 +44,7 @@ function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAno
|
||||
totalUsers={data.total_users}
|
||||
activeUsersByCountry={data.active_users_by_country ?? {}}
|
||||
interactive={interactive}
|
||||
initialPointOfView={viewerLocation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { US_CENTER } from '@/hooks/use-viewer-location';
|
||||
import { useWaitForIdle } from '@/hooks/use-wait-for-idle';
|
||||
import { useDashboardUser } from '@/lib/dashboard-user';
|
||||
import { useThemeWatcher } from '@/lib/theme';
|
||||
@ -359,7 +360,7 @@ type SatelliteHandle = {
|
||||
lastCountryCheckAt: number,
|
||||
};
|
||||
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) {
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
|
||||
const hasWaitedForIdle = useWaitForIdle(1000, 5000);
|
||||
if (!hasWaitedForIdle) {
|
||||
return <GlobeLoading devReason="waiting for cpu" />;
|
||||
@ -372,6 +373,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa
|
||||
activeUsersByCountry={activeUsersByCountry ?? {}}
|
||||
satelliteCount={satelliteCount ?? 2}
|
||||
interactive={interactive ?? false}
|
||||
initialPointOfView={initialPointOfView}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@ -466,7 +468,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) {
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
|
||||
const countries = use(countriesPromise);
|
||||
const projectId = useProjectId();
|
||||
const globeRef = useRef<GlobeMethods | undefined>(undefined);
|
||||
@ -677,7 +679,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
resumeRender();
|
||||
}, [cameraDistance, shouldShowGlobe, squareSize, interactive]);
|
||||
|
||||
|
||||
const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0);
|
||||
const totalPopulationInCountries = countries.features.reduce((acc, curr) => acc + curr.properties.POP_EST, 0);
|
||||
const oneSided95PercentZScore = 1.645;
|
||||
@ -1156,8 +1157,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
controls.enableZoom = interactive;
|
||||
controls.enableRotate = true;
|
||||
current.camera().position.z = cameraDistance;
|
||||
// Little Saint James Island, U.S. Virgin Islands
|
||||
current.pointOfView({ lat: 18.3076, lng: -64.8267 }, 0);
|
||||
current.pointOfView({ lat: initialPointOfView?.lat ?? US_CENTER.lat, lng: initialPointOfView?.lng ?? US_CENTER.lng }, 0);
|
||||
|
||||
// Fix z-fighting: Enable proper depth testing
|
||||
const renderer = current.renderer();
|
||||
|
||||
33
apps/dashboard/src/app/api/viewer-location/route.ts
Normal file
33
apps/dashboard/src/app/api/viewer-location/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Returns the dashboard viewer's approximate latitude and longitude,
|
||||
* derived from IP-based geolocation headers injected by the CDN/proxy
|
||||
* (Vercel, Cloudflare). No browser permissions are required.
|
||||
*
|
||||
* Returns `{ lat, lng }` when geo headers are available, otherwise
|
||||
* `{ lat: null, lng: null }` so the caller can fall back.
|
||||
*/
|
||||
export async function GET() {
|
||||
const allHeaders = await headers();
|
||||
|
||||
// Vercel injects lat/lng headers on every edge/serverless request.
|
||||
const vercelLat = allHeaders.get("x-vercel-ip-latitude");
|
||||
const vercelLng = allHeaders.get("x-vercel-ip-longitude");
|
||||
|
||||
if (vercelLat != null && vercelLng != null) {
|
||||
const lat = parseFloat(vercelLat);
|
||||
const lng = parseFloat(vercelLng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
return NextResponse.json({ lat, lng });
|
||||
}
|
||||
}
|
||||
|
||||
// Cloudflare provides a cf-ipcountry header but no lat/lng, so we can't
|
||||
// do much with it here. Fall back gracefully.
|
||||
|
||||
return NextResponse.json({ lat: null, lng: null });
|
||||
}
|
||||
67
apps/dashboard/src/hooks/use-viewer-location.tsx
Normal file
67
apps/dashboard/src/hooks/use-viewer-location.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ViewerLocation = { lat: number, lng: number };
|
||||
|
||||
// US geographic center fallback
|
||||
export const US_CENTER: ViewerLocation = { lat: 39.5, lng: -98.35 };
|
||||
|
||||
// Cache the result so we only fetch once per page load, even if multiple
|
||||
// components mount/unmount the hook.
|
||||
let cachedLocation: ViewerLocation | null = null;
|
||||
let fetchPromise: Promise<ViewerLocation> | null = null;
|
||||
|
||||
function fetchViewerLocation(): Promise<ViewerLocation> {
|
||||
if (fetchPromise != null) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/viewer-location");
|
||||
if (!res.ok) {
|
||||
fetchPromise = null;
|
||||
return US_CENTER;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (typeof data.lat === "number" && typeof data.lng === "number") {
|
||||
return { lat: data.lat, lng: data.lng };
|
||||
}
|
||||
} catch (e) {
|
||||
captureError("viewer-location-fetch", e instanceof Error ? e : new Error(String(e)));
|
||||
fetchPromise = null;
|
||||
}
|
||||
// Header data was missing or malformed; allow retry on next mount.
|
||||
fetchPromise = null;
|
||||
return US_CENTER;
|
||||
})();
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dashboard viewer's approximate location based on IP
|
||||
* geolocation (via CDN headers), falling back to the US center.
|
||||
* The fetch runs once per page load and the result is cached.
|
||||
*/
|
||||
export function useViewerLocation(): ViewerLocation {
|
||||
const [location, setLocation] = useState<ViewerLocation>(() => cachedLocation ?? US_CENTER);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedLocation != null) return;
|
||||
let cancelled = false;
|
||||
runAsynchronously(async () => {
|
||||
const loc = await fetchViewerLocation();
|
||||
// Only cache a successful (non-fallback) result so transient
|
||||
// failures can be retried on next mount.
|
||||
if (loc !== US_CENTER) {
|
||||
cachedLocation = loc;
|
||||
}
|
||||
if (!cancelled) setLocation(loc);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return location;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user