Merge branch 'main' into cloudflare

This commit is contained in:
hamster1963 2024-11-25 18:06:48 +08:00
commit 62c6002c17
13 changed files with 165 additions and 138 deletions

View File

@ -12,6 +12,6 @@ NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha. NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links="[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]" NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false NEXT_PUBLIC_ShowTagCount=false

View File

@ -1,141 +1,49 @@
import { countryCodeMapping, reverseCountryCodeMapping } from "@/lib/geo";
import { countryCoordinates } from "@/lib/geo-limit";
import { GetNezhaData } from "@/lib/serverFetch"; import { GetNezhaData } from "@/lib/serverFetch";
import * as turf from "@turf/turf";
import DottedMap from "dotted-map/without-countries";
import { geoJsonString } from "../../../lib/geo-json-string"; import { geoJsonString } from "../../../lib/geo-json-string";
import { mapJsonString } from "../../../lib/map-string";
import GlobalInfo from "./GlobalInfo"; import GlobalInfo from "./GlobalInfo";
import { InteractiveMap } from "./InteractiveMap";
interface GlobalProps {
countries?: string[];
}
export default async function ServerGlobal() { export default async function ServerGlobal() {
const nezhaServerList = await GetNezhaData(); const nezhaServerList = await GetNezhaData();
const countrytList: string[] = []; const countrytList: string[] = [];
const serverCounts: { [key: string]: number } = {};
nezhaServerList.result.forEach((server) => { nezhaServerList.result.forEach((server) => {
if (server.host.CountryCode) { if (server.host.CountryCode) {
server.host.CountryCode = server.host.CountryCode.toUpperCase(); const countryCode = server.host.CountryCode.toUpperCase();
if (!countrytList.includes(server.host.CountryCode)) { if (!countrytList.includes(countryCode)) {
countrytList.push(server.host.CountryCode); countrytList.push(countryCode);
} }
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1;
} }
}); });
return <Global countries={countrytList} />; countrytList.push("TW");
} countrytList.push("SG");
countrytList.push("RU");
export async function Global({ countries = [] }: GlobalProps) { const width = 900;
const map = new DottedMap({ map: JSON.parse(mapJsonString) }); const height = 500;
const countries_alpha3 = countries
.map((code) => countryCodeMapping[code])
.filter((code) => code !== undefined);
const geoJson = JSON.parse(geoJsonString); const geoJson = JSON.parse(geoJsonString);
const filteredFeatures = geoJson.features.filter(
countries_alpha3.forEach((countryCode) => { (feature: any) => feature.properties.iso_a3 !== "",
const feature = geoJson.features.find(
(f: any) => f.properties.iso_a3 === countryCode,
); );
if (feature) {
if (countryCode === "RUS") {
// 获取俄罗斯的多个边界
const bboxList = feature.geometry.coordinates.map((polygon: any) =>
turf.bbox({ type: "Polygon", coordinates: polygon }),
);
const spacing = 20; // 单位为千米
const options = { units: "kilometers" };
bboxList.forEach((bbox: any) => {
// @ts-expect-error ignore
const pointGrid = turf.pointGrid(bbox, spacing, options);
// 过滤出位于当前多边形内部的点
const pointsWithin = turf.pointsWithinPolygon(pointGrid, feature);
if (pointsWithin.features.length === 0) {
const centroid = turf.centroid(feature);
const [lng, lat] = centroid.geometry.coordinates;
map.addPin({
lat,
lng,
svgOptions: { color: "#FF4500", radius: 0.3 },
});
} else {
pointsWithin.features.forEach((point: any) => {
const [lng, lat] = point.geometry.coordinates;
map.addPin({
lat,
lng,
svgOptions: { color: "#FF4500", radius: 0.3 },
});
});
}
});
}
// 获取国家的边界框
const bbox = turf.bbox(feature);
const spacing = 40; // 单位为千米,值越小点越密集
const options = { units: "kilometers" };
// @ts-expect-error ignore
const pointGrid = turf.pointGrid(bbox, spacing, options);
// 过滤出位于国家多边形内部的点
const pointsWithin = turf.pointsWithinPolygon(pointGrid, feature);
// 如果没有点在多边形内部,则使用国家的中心点
if (pointsWithin.features.length === 0) {
const centroid = turf.centroid(feature);
const [lng, lat] = centroid.geometry.coordinates;
map.addPin({
lat,
lng,
svgOptions: { color: "#FF4500", radius: 0.3 },
});
} else {
pointsWithin.features.forEach((point: any) => {
const [lng, lat] = point.geometry.coordinates;
map.addPin({
lat,
lng,
svgOptions: { color: "#FF4500", radius: 0.3 },
});
});
}
} else {
// 如果找不到feature使用countryCoordinates中的坐标
const alpha2Code = reverseCountryCodeMapping[countryCode];
if (alpha2Code && countryCoordinates[alpha2Code]) {
const coordinates = countryCoordinates[alpha2Code];
map.addPin({
lat: coordinates.lat,
lng: coordinates.lng,
svgOptions: { color: "#FF4500", radius: 0.3 },
});
}
}
});
const finalMap = map.getSVG({
radius: 0.35,
color: "#D1D5DA",
shape: "circle",
});
return ( return (
<section className="flex flex-col gap-4 mt-[3.2px]"> <section className="flex flex-col gap-4 mt-[3.2px]">
<GlobalInfo countries={countries} /> <GlobalInfo countries={countrytList} />
<img <div className="w-full overflow-x-auto">
src={`data:image/svg+xml;utf8,${encodeURIComponent(finalMap)}`} <InteractiveMap
alt="World Map with Highlighted Countries" countries={countrytList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
/> />
</div>
</section> </section>
); );
} }

View File

@ -0,0 +1,114 @@
"use client";
import { countryCodeMapping } from "@/lib/geo";
import { geoEqualEarth, geoPath } from "d3-geo";
import { AnimatePresence, m } from "framer-motion";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface InteractiveMapProps {
countries: string[];
serverCounts: { [key: string]: number };
width: number;
height: number;
filteredFeatures: any[];
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
}: InteractiveMapProps) {
const t = useTranslations("Global");
const [tooltipData, setTooltipData] = useState<{
centroid: [number, number];
country: string;
count: number;
} | null>(null);
const countries_alpha3 = countries
.map((code) => countryCodeMapping[code])
.filter((code) => code !== undefined);
const projection = geoEqualEarth()
.scale(180)
.translate([width / 2, height / 2]);
const path = geoPath().projection(projection);
return (
<div className="relative w-full aspect-[2/1]">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="w-full h-auto"
>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries_alpha3.includes(
feature.properties.iso_a3,
);
const countryCode = Object.entries(countryCodeMapping).find(
([,alpha3]) => alpha3 === feature.properties.iso_a3,
)?.[0];
const serverCount = countryCode
? serverCounts[countryCode] || 0
: 0;
return (
<path
key={index}
d={path(feature) || ""}
className={
isHighlighted
? "fill-orange-500 hover:fill-orange-400 stroke-orange-500 dark:stroke-amber-900 dark:fill-amber-900 dark:hover:fill-amber-800 transition-all cursor-pointer"
: "fill-neutral-200/50 dark:fill-neutral-800 stroke-neutral-300/40 dark:stroke-neutral-700 stroke-[0.5]"
}
onMouseEnter={() => {
if (isHighlighted && path.centroid(feature)) {
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
});
}
}}
onMouseLeave={() => setTooltipData(null)}
/>
);
})}
</g>
</svg>
<AnimatePresence mode="wait">
{tooltipData && (
<m.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
className="absolute pointer-events-none bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(-50%, -50%)",
}}
>
<p className="font-medium">{tooltipData.country}</p>
<p className="text-neutral-600 dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</m.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -7,13 +7,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import getEnv from "@/lib/env-entry"; import getEnv from "@/lib/env-entry";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
function Header() { function Header() {
const t = useTranslations("Header"); const t = useTranslations("Header");
const { resolvedTheme } = useTheme();
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo"); const customLogo = getEnv("NEXT_PUBLIC_CustomLogo");
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle"); const customTitle = getEnv("NEXT_PUBLIC_CustomTitle");
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription"); const customDescription = getEnv("NEXT_PUBLIC_CustomDescription");
@ -35,14 +33,15 @@ function Header() {
width={40} width={40}
height={40} height={40}
alt="apple-touch-icon" alt="apple-touch-icon"
src={ src={customLogo ? customLogo : "/apple-touch-icon.png"}
customLogo className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0! dark:hidden"
? customLogo />
: resolvedTheme === "light" <img
? "/apple-touch-icon.png" width={40}
: "/apple-touch-icon-dark.png" height={40}
} alt="apple-touch-icon"
className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0!" src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0! hidden dark:block"
/> />
</div> </div>
{customTitle ? customTitle : "NezhaDash"} {customTitle ? customTitle : "NezhaDash"}

BIN
bun.lockb

Binary file not shown.

View File

@ -27,7 +27,7 @@ export function LanguageSwitcher() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50" className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50 dark:hover:bg-accent/50"
> >
{localeItems.find((item) => item.code === locale)?.name} {localeItems.find((item) => item.code === locale)?.name}
<span className="sr-only">Change language</span> <span className="sr-only">Change language</span>

View File

@ -28,7 +28,7 @@ export function ModeToggle() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50" className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50 dark:hover:bg-accent/50"
> >
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />

View File

@ -12,6 +12,6 @@ NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha. NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links="[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]" NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false NEXT_PUBLIC_ShowTagCount=false

View File

@ -93,7 +93,8 @@
"Global": { "Global": {
"Loading": "Loading...", "Loading": "Loading...",
"Distributions": "Servers are distributed in", "Distributions": "Servers are distributed in",
"Regions": "Regions" "Regions": "Regions",
"Servers": "servers"
}, },
"NotFoundPage": { "NotFoundPage": {
"h1_490-590_404NotFound": "404 Not Found", "h1_490-590_404NotFound": "404 Not Found",

View File

@ -93,7 +93,8 @@
"Global": { "Global": {
"Loading": "Loading...", "Loading": "Loading...",
"Distributions": "サーバーは", "Distributions": "サーバーは",
"Regions": "つの地域に分散されています" "Regions": "つの地域に分散されています",
"Servers": "サーバー"
}, },
"NotFoundPage": { "NotFoundPage": {
"h1_490-590_404NotFound": "404 見つかりませんでした", "h1_490-590_404NotFound": "404 見つかりませんでした",

View File

@ -93,7 +93,8 @@
"Global": { "Global": {
"Loading": "載入中...", "Loading": "載入中...",
"Distributions": "伺服器分佈在", "Distributions": "伺服器分佈在",
"Regions": "個地區" "Regions": "個地區",
"Servers": "個伺服器"
}, },
"NotFoundPage": { "NotFoundPage": {
"h1_490-590_404NotFound": "404 未找到", "h1_490-590_404NotFound": "404 未找到",

View File

@ -93,7 +93,8 @@
"Global": { "Global": {
"Loading": "加载中...", "Loading": "加载中...",
"Distributions": "服务器分布在", "Distributions": "服务器分布在",
"Regions": "个地区" "Regions": "个地区",
"Servers": "个服务器"
}, },
"NotFoundPage": { "NotFoundPage": {
"h1_490-590_404NotFound": "404 未找到", "h1_490-590_404NotFound": "404 未找到",

View File

@ -1,6 +1,6 @@
{ {
"name": "nezha-dash", "name": "nezha-dash",
"version": "1.4.10", "version": "1.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3040", "dev": "next dev -p 3040",
@ -23,13 +23,15 @@
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@turf/turf": "^7.1.0", "@turf/turf": "^7.1.0",
"@types/d3-geo": "^3.1.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"caniuse-lite": "^1.0.30001684", "caniuse-lite": "^1.0.30001684",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.5.13", "country-flag-icons": "^1.5.13",
"dotted-map": "^2.2.3", "d3-geo": "^3.1.1",
"d3-selection": "^3.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"flag-icons": "^7.2.3", "flag-icons": "^7.2.3",
"framer-motion": "^12.0.0-alpha.2", "framer-motion": "^12.0.0-alpha.2",
@ -48,7 +50,7 @@
"recharts": "^2.13.3", "recharts": "^2.13.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"swr": "^2.2.6-beta.4", "swr": "^2.2.6-beta.4",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript-eslint": "^8.15.0" "typescript-eslint": "^8.15.0"
}, },