feats: realtime globe and visits charts
This commit is contained in:
@@ -198,13 +198,13 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
Domain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
Shorten Service
|
||||
Shorten
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
Email Service
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
DNS Service
|
||||
Subdomain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Active
|
||||
|
||||
@@ -2,8 +2,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import Globe from "../../dashboard/urls/globe";
|
||||
import LiveLog from "../../dashboard/urls/live-logs";
|
||||
import UserUrlsList from "../../dashboard/urls/url-list";
|
||||
|
||||
@@ -25,17 +27,29 @@ export default async function DashboardPage() {
|
||||
link="/docs/short-urls"
|
||||
linkText="short urls."
|
||||
/>
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/admin"
|
||||
/>
|
||||
<LiveLog admin={true} />
|
||||
|
||||
<Tabs defaultValue="Links">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Links">Links</TabsTrigger>
|
||||
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="Links">
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/admin"
|
||||
/>
|
||||
<LiveLog admin={true} />
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
<Globe isAdmin />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { RealtimeChart } from "./realtime-chart";
|
||||
import RealtimeLogs from "./realtime-logs";
|
||||
|
||||
const RealtimeGlobe = dynamic(() => import("./realtime-globe"), { ssr: false });
|
||||
|
||||
export interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
lastUpdate?: Date;
|
||||
updatedAt?: Date;
|
||||
device?: string;
|
||||
browser?: string;
|
||||
userUrl?: {
|
||||
url: string;
|
||||
target: string;
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DatabaseLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
city: string;
|
||||
country: string;
|
||||
lastUpdate: Date;
|
||||
updatedAt: Date;
|
||||
device?: string;
|
||||
browser?: string;
|
||||
userUrl?: {
|
||||
url: string;
|
||||
target: string;
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function date2unix(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const mountedRef = useRef(true);
|
||||
const locationDataRef = useRef<Map<string, Location>>(new Map());
|
||||
const lastUpdateRef = useRef<string>();
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [timeRange, setTimeRange] = useState<string>("30min");
|
||||
const [time, setTime] = useState(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
|
||||
endAt: date2unix(now),
|
||||
};
|
||||
});
|
||||
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [chartData, setChartData] = useState<ChartData[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
totalClicks: 0,
|
||||
uniqueLocations: 0,
|
||||
rawRecords: 0,
|
||||
lastFetch: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createLocationKey = (lat: number, lng: number) => {
|
||||
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
|
||||
};
|
||||
|
||||
const processChartData = (locations: Location[]): ChartData[] => {
|
||||
const countBySegment: {
|
||||
[key: string]: { count: number; timestamp: number };
|
||||
} = {};
|
||||
const timestamps = locations
|
||||
.filter((loc) => loc.updatedAt)
|
||||
.map((loc) => new Date(loc.updatedAt || "").getTime());
|
||||
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
const timeRangeMinutes = (maxTime - minTime) / (1000 * 60);
|
||||
console.log("[sss]", timeRangeMinutes);
|
||||
|
||||
let segmentSizeMinutes: number;
|
||||
if (timeRangeMinutes <= 30) segmentSizeMinutes = 0.5;
|
||||
else if (timeRangeMinutes <= 60) segmentSizeMinutes = 2;
|
||||
else if (timeRangeMinutes <= 360) segmentSizeMinutes = 12;
|
||||
else if (timeRangeMinutes <= 720) segmentSizeMinutes = 24;
|
||||
else if (timeRangeMinutes <= 1440) segmentSizeMinutes = 36;
|
||||
else {
|
||||
segmentSizeMinutes = Math.ceil(timeRangeMinutes / 30);
|
||||
segmentSizeMinutes = Math.ceil(segmentSizeMinutes / 60) * 60;
|
||||
}
|
||||
|
||||
locations.forEach((loc) => {
|
||||
if (!loc.updatedAt) return;
|
||||
const date = new Date(loc.updatedAt);
|
||||
const minutesSinceStart = Math.floor(
|
||||
(date.getTime() - minTime) / (1000 * 60),
|
||||
);
|
||||
const segmentIndex = Math.floor(minutesSinceStart / segmentSizeMinutes);
|
||||
const segmentStartMinutes = segmentIndex * segmentSizeMinutes;
|
||||
const segmentDate = new Date(minTime + segmentStartMinutes * 60 * 1000);
|
||||
const timeKey = `${segmentDate.getHours().toString().padStart(2, "0")}:${segmentDate
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
|
||||
if (!countBySegment[timeKey]) {
|
||||
countBySegment[timeKey] = {
|
||||
count: 0,
|
||||
timestamp: segmentDate.getTime(),
|
||||
};
|
||||
}
|
||||
countBySegment[timeKey].count += loc.count;
|
||||
});
|
||||
|
||||
return Object.keys(countBySegment)
|
||||
.sort((a, b) => countBySegment[a].timestamp - countBySegment[b].timestamp)
|
||||
.map((time) => ({
|
||||
time,
|
||||
count: countBySegment[time].count,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendLocationData = (
|
||||
newData: DatabaseLocation[],
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
const locationMap = isInitialLoad
|
||||
? new Map()
|
||||
: new Map(locationDataRef.current);
|
||||
let totalNewClicks = 0;
|
||||
|
||||
newData.forEach((item) => {
|
||||
const lat = Math.round(item.latitude * 100) / 100;
|
||||
const lng = Math.round(item.longitude * 100) / 100;
|
||||
const key = createLocationKey(lat, lng);
|
||||
const clickCount = item.count || 1;
|
||||
|
||||
if (locationMap.has(key)) {
|
||||
const existing = locationMap.get(key)!;
|
||||
existing.count += clickCount;
|
||||
existing.lastUpdate = new Date(item.lastUpdate);
|
||||
} else {
|
||||
locationMap.set(key, {
|
||||
lat,
|
||||
lng,
|
||||
count: clickCount,
|
||||
city: item.city,
|
||||
country: item.country,
|
||||
lastUpdate: new Date(item.lastUpdate),
|
||||
device: item.device,
|
||||
browser: item.browser,
|
||||
userUrl: item.userUrl,
|
||||
updatedAt: item.updatedAt,
|
||||
});
|
||||
}
|
||||
totalNewClicks += clickCount;
|
||||
});
|
||||
|
||||
locationDataRef.current = locationMap;
|
||||
const updatedLocations = Array.from(locationMap.values());
|
||||
const totalCount = updatedLocations.reduce(
|
||||
(sum, loc) => sum + loc.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const normalizedLocations = updatedLocations.map((loc) => ({
|
||||
...loc,
|
||||
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
|
||||
}));
|
||||
|
||||
const chartData = processChartData(updatedLocations);
|
||||
|
||||
return {
|
||||
locations: normalizedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getLiveLocations = async (isInitialLoad = true) => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
startAt: time.startAt.toString(),
|
||||
endAt: time.endAt.toString(),
|
||||
isAdmin: isAdmin ? "true" : "false",
|
||||
...filters,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/url/admin/locations?${params}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
console.error("API Error:", result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData: DatabaseLocation[] = result.data || [];
|
||||
const {
|
||||
locations: processedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
totalCount,
|
||||
} = appendLocationData(rawData, isInitialLoad);
|
||||
|
||||
setStats({
|
||||
totalClicks: result.totalClicks || totalCount,
|
||||
uniqueLocations: processedLocations.length,
|
||||
rawRecords: result.rawRecords || rawData.length,
|
||||
lastFetch: result.timestamp,
|
||||
});
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLocations(processedLocations);
|
||||
setChartData(chartData);
|
||||
lastUpdateRef.current = result.timestamp;
|
||||
}
|
||||
|
||||
if (!isInitialLoad) {
|
||||
rawData.forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
createTrafficEvent(
|
||||
item.latitude,
|
||||
item.longitude,
|
||||
item.city || "Unknown",
|
||||
);
|
||||
}
|
||||
}, index * 100);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching live locations:", error);
|
||||
if (mountedRef.current) {
|
||||
setLocations([]);
|
||||
setChartData([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getRealtimeUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/url/admin/locations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lastUpdate: lastUpdateRef.current,
|
||||
startAt: time.startAt,
|
||||
endAt: time.endAt,
|
||||
isAdmin,
|
||||
...filters,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error || !result.data || result.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
locations: processedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
} = appendLocationData(result.data, false);
|
||||
|
||||
setStats((prev) => ({
|
||||
totalClicks: prev.totalClicks + totalNewClicks,
|
||||
uniqueLocations: processedLocations.length,
|
||||
rawRecords: prev.rawRecords + result.data.length,
|
||||
lastFetch: result.timestamp,
|
||||
}));
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLocations(processedLocations);
|
||||
setChartData(chartData);
|
||||
lastUpdateRef.current = result.timestamp;
|
||||
|
||||
result.data.forEach((item: DatabaseLocation, index: number) => {
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
createTrafficEvent(
|
||||
item.latitude,
|
||||
item.longitude,
|
||||
item.city || "Unknown",
|
||||
);
|
||||
}
|
||||
}, index * 100);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching realtime updates:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLocationData = () => {
|
||||
locationDataRef.current.clear();
|
||||
setLocations([]);
|
||||
setChartData([]);
|
||||
setStats({
|
||||
totalClicks: 0,
|
||||
uniqueLocations: 0,
|
||||
rawRecords: 0,
|
||||
lastFetch: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
if (mountedRef.current) {
|
||||
getRealtimeUpdates();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
resetLocationData();
|
||||
getLiveLocations(true);
|
||||
}
|
||||
}, [time, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const restoreTimeRange = () => {
|
||||
setTimeRange("30min");
|
||||
const now = new Date();
|
||||
setTime({
|
||||
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
|
||||
endAt: date2unix(now),
|
||||
});
|
||||
};
|
||||
|
||||
(window as any).restoreTimeRange = restoreTimeRange;
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
if (mountedRef.current) {
|
||||
restoreTimeRange();
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
delete (window as any).restoreTimeRange;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTrafficEventRef = useRef<
|
||||
(lat: number, lng: number, city: string) => void
|
||||
>(() => {});
|
||||
|
||||
const createTrafficEvent = (lat: number, lng: number, city: string) => {
|
||||
if (handleTrafficEventRef.current) {
|
||||
handleTrafficEventRef.current(lat, lng, city);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (value: string) => {
|
||||
setTimeRange(value);
|
||||
const now = new Date();
|
||||
const selectedRange = DAILY_DIMENSION_ENUMS.find((e) => e.value === value);
|
||||
if (!selectedRange) return;
|
||||
|
||||
const minutes = selectedRange.key;
|
||||
const startAt = date2unix(new Date(now.getTime() - minutes * 60 * 1000));
|
||||
const endAt = date2unix(now);
|
||||
|
||||
setTime({ startAt, endAt });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<RealtimeTimePicker
|
||||
timeRange={timeRange}
|
||||
setTimeRange={handleTimeRangeChange}
|
||||
/>
|
||||
<div className="p-4 sm:relative">
|
||||
<RealtimeChart
|
||||
className="left-0 top-2 z-10 sm:absolute"
|
||||
chartData={chartData}
|
||||
totalClicks={stats.totalClicks}
|
||||
/>
|
||||
<RealtimeGlobe
|
||||
time={time}
|
||||
filters={filters}
|
||||
locations={locations}
|
||||
stats={stats}
|
||||
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
|
||||
/>
|
||||
<RealtimeLogs
|
||||
className="right-0 top-2 z-10 sm:absolute"
|
||||
locations={locations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RealtimeTimePicker({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
}: {
|
||||
timeRange: string;
|
||||
setTimeRange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
|
||||
<SelectTrigger className="absolute -top-[46px] right-0 z-20 hidden w-60 sm:inline-flex">
|
||||
<SelectValue placeholder="Select a time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAILY_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem value={e.value}>
|
||||
<span className="flex items-center gap-1">{e.label}</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
interface ChartData {
|
||||
time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RealtimeChartProps {
|
||||
className?: string;
|
||||
chartData: ChartData[];
|
||||
totalClicks: number;
|
||||
}
|
||||
|
||||
export const RealtimeChart = ({
|
||||
className,
|
||||
chartData,
|
||||
totalClicks,
|
||||
}: RealtimeChartProps) => {
|
||||
const maxCount = Math.max(...chartData.map((d) => d.count), 1);
|
||||
// const tickInterval =
|
||||
// chartData.length <= 10 ? 0 : Math.ceil(chartData.length / 10);
|
||||
const getTickInterval = (dataLength: number) => {
|
||||
if (dataLength <= 6) return 0; // 显示所有刻度
|
||||
if (dataLength <= 12) return 1; // 每隔1个显示
|
||||
if (dataLength <= 24) return Math.ceil(dataLength / 8); // 大约8个刻度
|
||||
return Math.ceil(dataLength / 6); // 大约6个刻度
|
||||
};
|
||||
const tickInterval = getTickInterval(chartData.length);
|
||||
|
||||
return (
|
||||
<div className={cn(`rounded-lg border p-3 backdrop-blur-xl`, className)}>
|
||||
<div className="mb-1 flex items-center text-base font-semibold">
|
||||
<StatusDot status={1} />
|
||||
<h3 className="ml-2">Realtime Visits</h3>
|
||||
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
|
||||
<ResponsiveContainer width={300} height={200}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
|
||||
barCategoryGap={1}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={tickInterval}
|
||||
tickCount={Math.min(chartData.length, 10)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
type="category"
|
||||
scale="point"
|
||||
padding={{ left: 14, right: 20 }}
|
||||
tickFormatter={(value) => value}
|
||||
/>
|
||||
<YAxis
|
||||
// domain={[0, maxCount]}
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickCount={5}
|
||||
tick={{ fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-md bg-primary-foreground/90 p-2 text-sm backdrop-blur-lg">
|
||||
<p className="label">{`${label}`}</p>
|
||||
<p className="label">{`Visits: ${payload[0].value}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="#36d399"
|
||||
radius={[1, 1, 0, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { scaleSequentialSqrt } from "d3-scale";
|
||||
import { interpolateYlOrRd } from "d3-scale-chromatic";
|
||||
import { GlobeInstance } from "globe.gl";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
interface GlobeProps {
|
||||
time: {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
};
|
||||
filters: Record<string, any>;
|
||||
locations: Location[];
|
||||
stats: {
|
||||
totalClicks: number;
|
||||
uniqueLocations: number;
|
||||
rawRecords: number;
|
||||
lastFetch: string;
|
||||
};
|
||||
setHandleTrafficEvent: (
|
||||
fn: (lat: number, lng: number, city: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function RealtimeGlobe({
|
||||
time,
|
||||
filters,
|
||||
locations,
|
||||
stats,
|
||||
}: GlobeProps) {
|
||||
const globeRef = useRef<HTMLDivElement>(null);
|
||||
const globeInstanceRef = useRef<any>(null);
|
||||
const mountedRef = useRef(true);
|
||||
let globe: GlobeInstance;
|
||||
const [countries, setCountries] = useState<any>({});
|
||||
const [currentLocation, setCurrentLocation] = useState<any>({});
|
||||
const [hexAltitude, setHexAltitude] = useState(0.001);
|
||||
const {
|
||||
ref: wrapperRef,
|
||||
width: wrapperWidth,
|
||||
height: wrapperHeight,
|
||||
} = useElementSize();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const highest =
|
||||
locations.reduce((acc, curr) => Math.max(acc, curr.count), 0) || 1;
|
||||
const weightColor = scaleSequentialSqrt(interpolateYlOrRd).domain([
|
||||
0,
|
||||
highest * 15,
|
||||
]);
|
||||
|
||||
const loadGlobe = useCallback(async () => {
|
||||
try {
|
||||
const GlobeModule = await import("globe.gl");
|
||||
const Globe = GlobeModule.default;
|
||||
const { MeshPhongMaterial } = await import("three");
|
||||
return { Globe, MeshPhongMaterial };
|
||||
} catch (err) {
|
||||
console.error("Failed to load Globe.gl:", err);
|
||||
setError("Failed to load Globe.gl library");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getGlobeJSON = async () => {
|
||||
try {
|
||||
const response = await fetch("/countries.geojson");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCountries(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching globe JSON:", error);
|
||||
if (mountedRef.current) {
|
||||
setCountries({ type: "FeatureCollection", features: [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/location");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current location:", error);
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation({ latitude: 0, longitude: 0 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initGlobe = useCallback(async () => {
|
||||
if (
|
||||
!globeRef.current ||
|
||||
!countries.features ||
|
||||
globeInstanceRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modules = await loadGlobe();
|
||||
if (!modules || !mountedRef.current) return;
|
||||
|
||||
const { Globe, MeshPhongMaterial } = modules;
|
||||
|
||||
const container = globeRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
setTimeout(initGlobe, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
globe = new Globe(container)
|
||||
.width(wrapperWidth)
|
||||
.height(wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth)
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.8)")
|
||||
.backgroundColor("rgba(0,0,0,0)")
|
||||
.globeMaterial(
|
||||
new MeshPhongMaterial({
|
||||
color: "rgb(228, 228, 231)",
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
if (countries.features && countries.features.length > 0) {
|
||||
globe
|
||||
.hexPolygonsData(countries.features)
|
||||
.hexPolygonResolution(3)
|
||||
.hexPolygonMargin(0.2)
|
||||
.hexPolygonAltitude(() => hexAltitude)
|
||||
.hexPolygonColor(
|
||||
() => `rgba(54, 211, 153, ${Math.random() / 1.5 + 0.5})`,
|
||||
);
|
||||
}
|
||||
|
||||
globe
|
||||
.hexBinResolution(4)
|
||||
.hexBinPointsData(locations)
|
||||
.hexBinMerge(true)
|
||||
.hexBinPointWeight("count")
|
||||
.hexTopColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity);
|
||||
})
|
||||
.hexSideColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity * 0.8);
|
||||
})
|
||||
.hexAltitude((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return Math.max(0.01, intensity * 0.8);
|
||||
});
|
||||
|
||||
globe.onGlobeReady(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const lat = currentLocation.latitude || 0;
|
||||
const lng = currentLocation.longitude || 0;
|
||||
|
||||
globe.pointOfView({
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
altitude: rect.width > 768 ? 2.5 : 3.5,
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().autoRotate = true;
|
||||
globe.controls().autoRotateSpeed = 0.5;
|
||||
globe.controls().enableDamping = true;
|
||||
globe.controls().dampingFactor = 0.1;
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().addEventListener(
|
||||
"end",
|
||||
debounce(() => {
|
||||
if (!mountedRef.current || !globeInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
const distance = Math.round(globe.controls().getDistance());
|
||||
let nextAlt = 0.005;
|
||||
if (distance <= 300) nextAlt = 0.001;
|
||||
else if (distance >= 600) nextAlt = 0.02;
|
||||
|
||||
if (nextAlt !== hexAltitude) {
|
||||
setHexAltitude(nextAlt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error in controls event:", err);
|
||||
}
|
||||
}, 200),
|
||||
);
|
||||
}
|
||||
|
||||
globeInstanceRef.current = globe;
|
||||
|
||||
// console.log("Globe initialization complete");
|
||||
} catch (err) {
|
||||
// console.error("Error initializing globe:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to initialize globe",
|
||||
);
|
||||
}
|
||||
}, [
|
||||
countries,
|
||||
locations,
|
||||
currentLocation,
|
||||
hexAltitude,
|
||||
loadGlobe,
|
||||
weightColor,
|
||||
]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (globeInstanceRef.current) {
|
||||
try {
|
||||
if (typeof globeInstanceRef.current._destructor === "function") {
|
||||
globeInstanceRef.current._destructor();
|
||||
}
|
||||
|
||||
if (globeRef.current) {
|
||||
globeRef.current.innerHTML = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error during cleanup:", err);
|
||||
}
|
||||
globeInstanceRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoaded(false);
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (globeInstanceRef.current) {
|
||||
// globeInstanceRef.current.width(wrapperWidth);
|
||||
// globeInstanceRef.current.height(
|
||||
// wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth,
|
||||
// );
|
||||
// }
|
||||
// }, [globeInstanceRef.current, wrapperWidth, wrapperHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
globeInstanceRef.current &&
|
||||
mountedRef.current &&
|
||||
locations.length > 0
|
||||
) {
|
||||
try {
|
||||
globeInstanceRef.current.hexBinPointsData(locations);
|
||||
} catch (err) {
|
||||
console.warn("Error updating locations:", err);
|
||||
}
|
||||
}
|
||||
}, [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
await Promise.all([getCurrentLocation(), getGlobeJSON()]);
|
||||
} catch (error) {
|
||||
console.error("Error initializing data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
countries.features &&
|
||||
currentLocation &&
|
||||
!globeInstanceRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
const timer = setTimeout(initGlobe, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countries, currentLocation, initGlobe]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative -mt-10">
|
||||
<div
|
||||
ref={globeRef}
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
// width: `${wrapperWidth}px`,
|
||||
// height:
|
||||
// wrapperWidth > 728
|
||||
// ? `${wrapperWidth * 0.8}px`
|
||||
// : `${wrapperWidth}px`,
|
||||
maxWidth: `${wrapperWidth}px`, // 比较疑惑
|
||||
minHeight: "100px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import ReactCountryFlag from "react-country-flag";
|
||||
|
||||
import { formatTime } from "@/lib/utils";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
const RealtimeLogs = ({
|
||||
className,
|
||||
locations,
|
||||
}: {
|
||||
className?: string;
|
||||
locations: Location[];
|
||||
}) => {
|
||||
const [displayedLocations, setDisplayedLocations] = useState<Location[]>([]);
|
||||
const [pendingLocations, setPendingLocations] = useState<Location[]>([]);
|
||||
|
||||
// 生成唯一标识用于去重
|
||||
const generateUniqueKey = (loc: Location): string => {
|
||||
return `${loc.userUrl?.url || ""}-${loc.userUrl?.target || ""}-${loc.userUrl?.prefix || ""}-${loc.country || ""}-${loc.city || ""}-${loc.browser || ""}-${loc.device || ""}-${loc.updatedAt?.toString() || ""}`;
|
||||
};
|
||||
|
||||
// 当外部 locations 更新时,更新预备列表
|
||||
useEffect(() => {
|
||||
const sortedLocations = [...locations].sort((a, b) => {
|
||||
const timeA = new Date(a.updatedAt?.toString() || "").getTime() || 0;
|
||||
const timeB = new Date(b.updatedAt?.toString() || "").getTime() || 0;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
setPendingLocations((prev) => {
|
||||
// 去重:基于多个字段判断
|
||||
const newLocations = sortedLocations.filter(
|
||||
(loc) =>
|
||||
!prev.some((p) => generateUniqueKey(p) === generateUniqueKey(loc)) &&
|
||||
!displayedLocations.some(
|
||||
(d) => generateUniqueKey(d) === generateUniqueKey(loc),
|
||||
),
|
||||
);
|
||||
return [...prev, ...newLocations];
|
||||
});
|
||||
}, [locations]);
|
||||
|
||||
// 每 2 秒从预备列表插入一条数据到显示列表
|
||||
useEffect(() => {
|
||||
if (pendingLocations.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDisplayedLocations((prev) => {
|
||||
if (pendingLocations.length > 0) {
|
||||
const newLocation = pendingLocations[0];
|
||||
// 插入新数据到顶部,限制显示列表最多 8 条
|
||||
const newDisplayed = [newLocation, ...prev].slice(0, 8);
|
||||
// 从预备列表移除已插入的数据
|
||||
setPendingLocations((pending) => pending.slice(1));
|
||||
return newDisplayed;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingLocations]);
|
||||
|
||||
// 动画配置
|
||||
const itemVariants = {
|
||||
initial: { opacity: 0, scale: 0.1, x: "25%", y: "25%" }, // 从中心缩放
|
||||
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
|
||||
// exit: { opacity: 0, transition: { duration: 0.3 } }, // 渐出
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${className}`}
|
||||
style={{ minHeight: "200px", maxHeight: "80vh" }}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{displayedLocations.length > 0 &&
|
||||
displayedLocations.map((loc) => (
|
||||
<motion.div
|
||||
key={generateUniqueKey(loc)}
|
||||
variants={itemVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-2 flex w-full items-center justify-start gap-3 rounded-lg border p-3 text-xs shadow-inner backdrop-blur-xl sm:w-60"
|
||||
>
|
||||
<ReactCountryFlag
|
||||
style={{ fontSize: "16px" }}
|
||||
countryCode={loc.country || "US"}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
className="text-sm font-semibold"
|
||||
href={`https://${loc.userUrl?.prefix}/s/${loc.userUrl?.url}`}
|
||||
target="_blank"
|
||||
>
|
||||
{loc.userUrl?.url}
|
||||
</Link>
|
||||
<span className="font-semibold">·</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(loc.updatedAt?.toString() || "")}
|
||||
</span>
|
||||
</div>
|
||||
{loc.browser && loc.browser !== "Unknown" && (
|
||||
<div className="mt-1 line-clamp-1 break-words font-medium text-muted-foreground">
|
||||
{loc.browser}
|
||||
{loc.device &&
|
||||
loc.device !== "Unknown" &&
|
||||
`${", "}${loc.device}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealtimeLogs;
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -235,22 +236,26 @@ export function DailyPVUVChart({
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -58,23 +59,26 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
className=""
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import ApiReference from "../../../../components/shared/api-reference";
|
||||
import Globe from "./globe";
|
||||
import LiveLog from "./live-logs";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
@@ -26,22 +28,34 @@ export default async function DashboardPage() {
|
||||
link="/docs/short-urls"
|
||||
linkText="short urls."
|
||||
/>
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url"
|
||||
/>
|
||||
<LiveLog admin={false} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
<Tabs defaultValue="Links">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Links">Links</TabsTrigger>
|
||||
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="Links">
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url"
|
||||
/>
|
||||
<LiveLog admin={false} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
<Globe />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,10 +46,7 @@ import { UrlForm } from "@/components/forms/url-form";
|
||||
import { CopyButton } from "@/components/shared/copy-button";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import {
|
||||
LinkInfoPreviewer,
|
||||
LinkPreviewer,
|
||||
} from "@/components/shared/link-previewer";
|
||||
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
import QRCodeEditor from "@/components/shared/qr";
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { geolocation } from "@vercel/functions";
|
||||
|
||||
interface CurrentLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const geo = geolocation(req);
|
||||
|
||||
const location: CurrentLocation = {
|
||||
latitude: Number(geo.latitude || "0"),
|
||||
longitude: Number(geo.longitude || "0"),
|
||||
};
|
||||
|
||||
return NextResponse.json(location, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching location:", error);
|
||||
// Fallback to default coordinates
|
||||
return NextResponse.json(
|
||||
{ latitude: 40.7128, longitude: -74.006 },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const isAdmin = searchParams.get("isAdmin");
|
||||
if (isAdmin === "true") {
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const startAtParam = searchParams.get("startAt");
|
||||
const endAtParam = searchParams.get("endAt");
|
||||
const country = searchParams.get("country");
|
||||
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (startAtParam && endAtParam) {
|
||||
startDate = new Date(parseInt(startAtParam) * 1000);
|
||||
endDate = new Date(parseInt(endAtParam) * 1000);
|
||||
} else {
|
||||
endDate = new Date();
|
||||
startDate = new Date(Date.now() - 30 * 60 * 1000); // 30分钟前
|
||||
}
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
throw new Error("Invalid startAt or endAt parameters");
|
||||
}
|
||||
|
||||
const whereClause: any = {
|
||||
...(isAdmin === "true" ? {} : { userUrl: { userId: user.id } }),
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
latitude: {
|
||||
not: null,
|
||||
},
|
||||
longitude: {
|
||||
not: null,
|
||||
},
|
||||
};
|
||||
|
||||
if (country && country !== "") {
|
||||
whereClause.country = country;
|
||||
}
|
||||
|
||||
const rawData = await prisma.urlMeta.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
click: true,
|
||||
city: true,
|
||||
country: true,
|
||||
device: true,
|
||||
browser: true,
|
||||
updatedAt: true,
|
||||
userUrl: {
|
||||
select: {
|
||||
url: true,
|
||||
target: true,
|
||||
prefix: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 2000,
|
||||
});
|
||||
|
||||
// console.log("Raw data fetched:", rawData.length, "records");
|
||||
|
||||
const locationMap = new Map<
|
||||
string,
|
||||
{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
city: string;
|
||||
country: string;
|
||||
lastUpdate: Date;
|
||||
updatedAt: Date;
|
||||
device: string;
|
||||
browser: string;
|
||||
userUrl: {
|
||||
url: string;
|
||||
target: string;
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
>();
|
||||
|
||||
rawData.forEach((item) => {
|
||||
if (item.latitude && item.longitude) {
|
||||
const lat = Math.round(Number(item.latitude) * 100) / 100;
|
||||
const lng = Math.round(Number(item.longitude) * 100) / 100;
|
||||
const key = `${lat},${lng}`;
|
||||
|
||||
if (locationMap.has(key)) {
|
||||
const existing = locationMap.get(key)!;
|
||||
existing.count += item.click || 1;
|
||||
if (item.updatedAt > existing.lastUpdate) {
|
||||
existing.lastUpdate = item.updatedAt;
|
||||
existing.city = item.city || existing.city;
|
||||
existing.country = item.country || existing.country;
|
||||
}
|
||||
} else {
|
||||
locationMap.set(key, {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
count: item.click || 1,
|
||||
city: item.city || "",
|
||||
country: item.country || "",
|
||||
lastUpdate: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
device: item.device || "",
|
||||
browser: item.browser || "",
|
||||
userUrl: item.userUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const aggregatedData = Array.from(locationMap.values()).sort(
|
||||
(a, b) => b.count - a.count,
|
||||
);
|
||||
// .slice(0, 500);
|
||||
|
||||
const totalClicks = aggregatedData.reduce(
|
||||
(sum, item) => sum + item.count,
|
||||
0,
|
||||
);
|
||||
const uniqueLocations = aggregatedData.length;
|
||||
|
||||
// console.log(
|
||||
// `Fetched ${rawData.length} records, aggregated to ${uniqueLocations} locations, total clicks: ${totalClicks}`,
|
||||
// );
|
||||
|
||||
return NextResponse.json({
|
||||
data: aggregatedData,
|
||||
total: uniqueLocations,
|
||||
totalClicks,
|
||||
rawRecords: rawData.length,
|
||||
timeRange: {
|
||||
startAt: startDate.toISOString(),
|
||||
endAt: endDate.toISOString(),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching location data:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: [],
|
||||
total: 0,
|
||||
totalClicks: 0,
|
||||
rawRecords: 0,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch location data",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
|
||||
// POST endpoint remains the same
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const body = await request.json();
|
||||
const { lastUpdate, isAdmin } = body;
|
||||
|
||||
if (isAdmin) {
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sinceDate = lastUpdate
|
||||
? new Date(lastUpdate)
|
||||
: new Date(Date.now() - 5000);
|
||||
|
||||
// console.log("lastUpdate", lastUpdate, sinceDate);
|
||||
|
||||
if (isNaN(sinceDate.getTime())) {
|
||||
throw new Error("Invalid lastUpdate parameter");
|
||||
}
|
||||
|
||||
const whereClause: any = {
|
||||
...(isAdmin ? {} : { userUrl: { userId: user.id } }),
|
||||
updatedAt: {
|
||||
gt: sinceDate,
|
||||
},
|
||||
latitude: {
|
||||
not: null,
|
||||
},
|
||||
longitude: {
|
||||
not: null,
|
||||
},
|
||||
};
|
||||
|
||||
const newData = await prisma.urlMeta.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
click: true,
|
||||
city: true,
|
||||
country: true,
|
||||
device: true,
|
||||
browser: true,
|
||||
updatedAt: true,
|
||||
userUrl: {
|
||||
select: {
|
||||
url: true,
|
||||
target: true,
|
||||
prefix: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 1000,
|
||||
});
|
||||
|
||||
// console.log("Realtime updates fetched:", newData.length, "records");
|
||||
|
||||
return NextResponse.json({
|
||||
data: newData,
|
||||
count: newData.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching realtime updates:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: [],
|
||||
count: 0,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch realtime updates",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
@@ -122,10 +123,13 @@ export function InteractiveBarChart() {
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem value={e.value}>{e.label}</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
interface ElementSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface UseElementSizeOptions {
|
||||
box?: "content-box" | "border-box" | "device-pixel-content-box";
|
||||
}
|
||||
|
||||
export function useElementSize<T extends HTMLElement>(
|
||||
initialSize: ElementSize = { width: 0, height: 0 },
|
||||
options: UseElementSizeOptions = {},
|
||||
): { ref: React.RefObject<T>; width: number; height: number } {
|
||||
const { box = "content-box" } = options;
|
||||
const [size, setSize] = useState<ElementSize>(initialSize);
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
// 检查是否为 SVG 元素
|
||||
const isSVG = useCallback(
|
||||
() => ref.current?.namespaceURI?.includes("svg"),
|
||||
[],
|
||||
);
|
||||
|
||||
// 更新尺寸的防抖函数
|
||||
const updateSize = useCallback(
|
||||
debounce((newSize: ElementSize) => {
|
||||
setSize((prev) =>
|
||||
prev.width === newSize.width && prev.height === newSize.height
|
||||
? prev
|
||||
: newSize,
|
||||
);
|
||||
}, 100),
|
||||
[],
|
||||
);
|
||||
|
||||
// 初始化尺寸
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const element = ref.current;
|
||||
if (element) {
|
||||
updateSize({
|
||||
width:
|
||||
"offsetWidth" in element ? element.offsetWidth : initialSize.width,
|
||||
height:
|
||||
"offsetHeight" in element ? element.offsetHeight : initialSize.height,
|
||||
});
|
||||
}
|
||||
}, [initialSize, updateSize]);
|
||||
|
||||
// 监听尺寸变化
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.ResizeObserver) return;
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
updateSize({ width: initialSize.width, height: initialSize.height });
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new window.ResizeObserver(([entry]) => {
|
||||
const boxSize =
|
||||
box === "border-box"
|
||||
? entry.borderBoxSize
|
||||
: box === "content-box"
|
||||
? entry.contentBoxSize
|
||||
: entry.devicePixelContentBoxSize;
|
||||
|
||||
if (isSVG()) {
|
||||
const rect = entry.target.getBoundingClientRect();
|
||||
updateSize({ width: rect.width, height: rect.height });
|
||||
} else if (boxSize) {
|
||||
const formatBoxSize = Array.isArray(boxSize) ? boxSize : [boxSize];
|
||||
const width = formatBoxSize.reduce(
|
||||
(acc, { inlineSize }) => acc + inlineSize,
|
||||
0,
|
||||
);
|
||||
const height = formatBoxSize.reduce(
|
||||
(acc, { blockSize }) => acc + blockSize,
|
||||
0,
|
||||
);
|
||||
updateSize({ width, height });
|
||||
} else {
|
||||
updateSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(element);
|
||||
observer.disconnect();
|
||||
updateSize.cancel();
|
||||
};
|
||||
}, [box, initialSize, isSVG, updateSize]);
|
||||
|
||||
return { ref, width: size.width, height: size.height };
|
||||
}
|
||||
@@ -312,3 +312,13 @@ export const DATE_DIMENSION_ENUMS = [
|
||||
{ value: "365d", label: "Last 1 Year", key: 365 },
|
||||
{ value: "All", label: "All the time", key: 1000 },
|
||||
] as const;
|
||||
|
||||
export const DAILY_DIMENSION_ENUMS = [
|
||||
{ value: "5min", label: "Last 5 Minutes", key: 5 },
|
||||
{ value: "10min", label: "Last 10 Minutes", key: 10 },
|
||||
{ value: "30min", label: "Last 30 Minutes", key: 30 },
|
||||
{ value: "1h", label: "Last 1 Hour", key: 60 },
|
||||
{ value: "6h", label: "Last 6 Hours", key: 360 },
|
||||
{ value: "12h", label: "Last 12 Hours", key: 720 },
|
||||
{ value: "24h", label: "Last 24 Hours", key: 1440 },
|
||||
] as const;
|
||||
|
||||
@@ -88,6 +88,18 @@ export function formatDate(input: string | number): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(input: string | number): string {
|
||||
const date = new Date(input);
|
||||
|
||||
const locale = navigator.language || "en-US";
|
||||
|
||||
return date.toLocaleTimeString(locale, {
|
||||
second: "numeric",
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
return `${env.NEXT_PUBLIC_APP_URL}${path}`;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -55,6 +56,10 @@
|
||||
"@react-email/html": "0.0.8",
|
||||
"@scaleway/random-name": "^5.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.11.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-scale-chromatic": "^3.1.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/three": "^0.176.0",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.26",
|
||||
"@unovis/react": "^1.4.3",
|
||||
@@ -69,9 +74,13 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"contentlayer2": "^0.5.0",
|
||||
"crypto": "^1.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"globe.gl": "^2.41.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.414.0",
|
||||
"lucide-static": "^0.460.0",
|
||||
"minimist": "^1.2.8",
|
||||
@@ -88,10 +97,12 @@
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-email": "2.1.5",
|
||||
"react-globe.gl": "^2.33.2",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
@@ -103,6 +114,7 @@
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.176.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vaul": "^0.9.1",
|
||||
|
||||
Generated
+421
-33
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+36
-36
@@ -1,39 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-21T11:16:00.755Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-21T11:16:00.756Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-23T15:05:05.510Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -88,6 +88,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.globe-tooltip {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
color: white !important;
|
||||
padding: 8px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 12px !important;
|
||||
z-index: 1000 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.text-gradient_indigo-purple {
|
||||
background: linear-gradient(90deg, #6366f1 0%, rgb(168 85 247 / 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
|
||||
Vendored
+2
@@ -47,3 +47,5 @@ export type DocsConfig = {
|
||||
mainNav: MainNavItem[];
|
||||
sidebarNav: SidebarNavItem[];
|
||||
};
|
||||
|
||||
// declare module "globe.gl";
|
||||
|
||||
Reference in New Issue
Block a user