feats: realtime globe and visits charts

This commit is contained in:
oiov
2025-05-24 17:28:25 +08:00
parent 6e8b1ccefd
commit a1cd74e90f
24 changed files with 28047 additions and 141 deletions

View File

@@ -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

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -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";

27
app/api/location/route.ts Normal file
View File

@@ -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 },
);
}
}

View File

@@ -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();
// }
}

View File

@@ -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>

102
hooks/use-element-size.ts Normal file
View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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}`;
}

View File

@@ -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",

454
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1
public/colos.json Normal file

File diff suppressed because one or more lines are too long

26017
public/countries.geojson Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

@@ -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;

2
types/index.d.ts vendored
View File

@@ -47,3 +47,5 @@ export type DocsConfig = {
mainNav: MainNavItem[];
sidebarNav: SidebarNavItem[];
};
// declare module "globe.gl";