feat(Compare): 增加延迟对比功能
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.a6.wiki",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ahdoawhfo",
|
||||
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__codex__codex",
|
||||
"mcp__gemini__gemini"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/LatencyTest.iml
generated
9
.idea/LatencyTest.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
68
.idea/inspectionProfiles/Project_Default.xml
generated
68
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,68 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="AliAccessStaticViaInstance" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliArrayNamingShouldHaveBracket" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliControlFlowStatementWithoutBraces" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliDeprecation" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliEqualsAvoidNull" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliLongLiteralsEndingWithLowercaseL" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliMissingOverrideAnnotation" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AliWrapperTypeEquality" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAbstractClassShouldStartWithAbstractNaming" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAbstractMethodOrInterfaceMethodMustUseJavadoc" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidApacheBeanUtilsCopy" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidCallStaticSimpleDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidCommentBehindStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidComplexCondition" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidConcurrentCompetitionRandom" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidDoubleOrFloatEqualCompare" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidManuallyCreateThread" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidMissUseOfMathRandom" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidNegationOperator" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidNewDateGetTime" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidPatternCompileInMethod" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidReturnInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidStartWithDollarAndUnderLineNaming" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaAvoidUseTimer" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaBigDecimalAvoidDoubleConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaBooleanPropertyShouldNotStartWithIs" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaClassCastExceptionWithSubListToArrayList" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaClassCastExceptionWithToArray" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaClassMustHaveAuthor" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaClassNamingShouldBeCamel" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaCollectionInitShouldAssignCapacity" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaCommentsMustBeJavadocFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaConcurrentExceptionWithModifyOriginSubList" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaConstantFieldShouldBeUpperCase" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaCountDownShouldInFinally" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaDontModifyInForeachCircle" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaEnumConstantsMustHaveComment" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaExceptionClassShouldEndWithException" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaIbatisMethodQueryForList" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaLockShouldWithTryFinally" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaLowerCamelCaseVariableNaming" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaMethodReturnWrapperType" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaMethodTooLong" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaPackageNaming" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaPojoMustOverrideToString" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaPojoMustUsePrimitiveField" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaPojoNoDefaultValue" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaRemoveCommentedCode" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaServiceOrDaoClassShouldEndWithImpl" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaSneakyThrowsWithoutExceptionType" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaStringConcat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaSwitchExpression" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaTestClassShouldEndWithTestNaming" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaThreadLocalShouldRemove" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaThreadPoolCreation" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaThreadShouldSetName" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaTransactionMustHaveRollback" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaUndefineMagicConstant" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaUnsupportedExceptionWithModifyAsList" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaUseQuietReferenceNotation" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AlibabaUseRightCaseForDateFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="MapOrSetKeyShouldOverrideHashCodeEquals" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/LatencyTest-CF.iml" filepath="$PROJECT_DIR$/.idea/LatencyTest-CF.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-globe.gl": "^2.37.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
@@ -3882,6 +3883,57 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router/node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-maps": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
||||
@@ -4109,6 +4161,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-globe.gl": "^2.37.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
|
||||
@@ -1,69 +1,45 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import FloatingHeader from './components/FloatingHeader'
|
||||
import IpInput from './components/IpInput'
|
||||
import LatencyMap from './components/LatencyMap'
|
||||
import ResultsPanel from './components/ResultsPanel'
|
||||
import { testAllNodes } from './api/latency'
|
||||
import { LatencyResult } from '@shared/types'
|
||||
import HomePage from './components/HomePage'
|
||||
import ComparePage from './components/ComparePage'
|
||||
import './styles/index.css'
|
||||
|
||||
function AppContent() {
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleTest = useCallback(async (target: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
|
||||
await testAllNodes(target, (result) => {
|
||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||
})
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<FloatingHeader />
|
||||
|
||||
<main className="app-main" style={{ paddingTop: '8rem' }}>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>{t('© 2025 全球延迟测试。由 GlobalPing 提供服务支持。', '© 2025 Global Latency Test. Powered by GlobalPing.')}</p>
|
||||
<p>{t('Copyright © by ahdoawhfo All Rights Reserved. ', 'Copyright © by ahdoawhfo All Rights Reserved. ')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/compare" element={<ComparePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
<AppRoutes />
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
310
src/client/components/ComparePage.css
Normal file
310
src/client/components/ComparePage.css
Normal file
@@ -0,0 +1,310 @@
|
||||
.compare-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.75rem;
|
||||
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.compare-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.compare-input {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1rem;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.compare-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.compare-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.compare-input.error {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.compare-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--error-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compare-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 48px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compare-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(255,255,255,0.15), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled)::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compare-button:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.compare-button .spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Results Table */
|
||||
.results-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background: var(--hover-bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.results-table tbody tr:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.results-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.node-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-city {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-country {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.latency-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.latency-cell.better {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.latency-cell.worse {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.latency-cell.testing {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-positive {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.diff-negative {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.diff-neutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.compare-summary {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.summary-value.winner-a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.summary-value.winner-b {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compare-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.compare-summary {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
226
src/client/components/ComparePage.tsx
Normal file
226
src/client/components/ComparePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState } from 'react'
|
||||
import { testAllNodes } from '../api/latency'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { LatencyResult, TEST_NODES } from '@shared/types'
|
||||
import './ComparePage.css'
|
||||
|
||||
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
||||
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
function isValidTarget(value: string): boolean {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
||||
}
|
||||
|
||||
export default function ComparePage() {
|
||||
const { t } = useLanguage()
|
||||
const [targetA, setTargetA] = useState('')
|
||||
const [targetB, setTargetB] = useState('')
|
||||
const [resultsA, setResultsA] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [resultsB, setResultsB] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [errors, setErrors] = useState({ a: '', b: '' })
|
||||
|
||||
const handleCompare = async () => {
|
||||
const aValid = isValidTarget(targetA)
|
||||
const bValid = isValidTarget(targetB)
|
||||
|
||||
setErrors({
|
||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||
b: bValid ? '' : t('无效的目标', 'Invalid target')
|
||||
})
|
||||
|
||||
if (!aValid || !bValid) return
|
||||
|
||||
setTesting(true)
|
||||
setResultsA(new Map())
|
||||
setResultsB(new Map())
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
testAllNodes(targetA.trim(), (res) => {
|
||||
setResultsA(prev => new Map(prev).set(res.nodeId, res))
|
||||
}),
|
||||
testAllNodes(targetB.trim(), (res) => {
|
||||
setResultsB(prev => new Map(prev).set(res.nodeId, res))
|
||||
})
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('Comparison test failed:', e)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDiff = (a: number | null, b: number | null) => {
|
||||
if (a === null || b === null) return null
|
||||
return b - a
|
||||
}
|
||||
|
||||
const getDiffDisplay = (diff: number | null) => {
|
||||
if (diff === null) return <span className="diff-neutral">-</span>
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const className = diff > 0 ? 'diff-positive' : diff < 0 ? 'diff-negative' : 'diff-neutral'
|
||||
return <span className={className}>{sign}{diff.toFixed(0)} ms</span>
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
const calculateSummary = () => {
|
||||
let winsA = 0, winsB = 0, totalDiff = 0, count = 0
|
||||
|
||||
for (const node of TEST_NODES) {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
|
||||
if (lA !== null && lB !== null) {
|
||||
if (lA < lB) winsA++
|
||||
else if (lB < lA) winsB++
|
||||
totalDiff += lB - lA
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return { winsA, winsB, avgDiff: count > 0 ? totalDiff / count : 0 }
|
||||
}
|
||||
|
||||
const summary = calculateSummary()
|
||||
const hasResults = resultsA.size > 0 || resultsB.size > 0
|
||||
|
||||
return (
|
||||
<div className="compare-page">
|
||||
<div className="compare-header">
|
||||
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||
<p className="compare-subtitle">
|
||||
{t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="compare-inputs">
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.a ? 'error' : ''}`}
|
||||
value={targetA}
|
||||
onChange={(e) => { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.a && <span className="input-error">{errors.a}</span>}
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.b ? 'error' : ''}`}
|
||||
value={targetB}
|
||||
onChange={(e) => { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.b && <span className="input-error">{errors.b}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-actions">
|
||||
<button
|
||||
className="compare-button"
|
||||
onClick={handleCompare}
|
||||
disabled={testing || !targetA.trim() || !targetB.trim()}
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
{t('测试中...', 'Testing...')}
|
||||
</>
|
||||
) : (
|
||||
t('开始对比', 'Start Comparison')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasResults && (
|
||||
<>
|
||||
<div className="results-container">
|
||||
<table className="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('节点', 'Node')}</th>
|
||||
<th>{targetA || 'A'}</th>
|
||||
<th>{targetB || 'B'}</th>
|
||||
<th>{t('差值', 'Diff')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TEST_NODES.map(node => {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
const diff = getDiff(lA, lB)
|
||||
|
||||
const getLatencyClass = (mine: number | null, other: number | null, status?: string) => {
|
||||
if (status === 'testing') return 'latency-cell testing'
|
||||
if (mine === null || other === null) return 'latency-cell'
|
||||
if (mine < other) return 'latency-cell better'
|
||||
if (mine > other) return 'latency-cell worse'
|
||||
return 'latency-cell'
|
||||
}
|
||||
|
||||
const formatLatency = (latency: number | null, status?: string) => {
|
||||
if (status === 'testing' || status === 'pending') return '...'
|
||||
if (latency === null) return '-'
|
||||
return `${latency.toFixed(0)} ms`
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={node.id}>
|
||||
<td>
|
||||
<div className="node-cell">
|
||||
<span className="node-city">{node.city || node.name}</span>
|
||||
<span className="node-country">{node.country}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={getLatencyClass(lA, lB, rA?.status)}>
|
||||
{formatLatency(lA, rA?.status)}
|
||||
</td>
|
||||
<td className={getLatencyClass(lB, lA, rB?.status)}>
|
||||
{formatLatency(lB, rB?.status)}
|
||||
</td>
|
||||
<td className="diff-cell">
|
||||
{getDiffDisplay(diff)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!testing && (summary.winsA > 0 || summary.winsB > 0) && (
|
||||
<div className="compare-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('A 胜出节点', 'A Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsA > summary.winsB ? 'winner-a' : ''}`}>
|
||||
{summary.winsA}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('B 胜出节点', 'B Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsB > summary.winsA ? 'winner-b' : ''}`}>
|
||||
{summary.winsB}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('平均差值', 'Avg Diff')}</span>
|
||||
<span className="summary-value">
|
||||
{summary.avgDiff > 0 ? '+' : ''}{summary.avgDiff.toFixed(0)} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,19 +30,43 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
/* Brand Section - Premium Style */
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.header-title .title-icon {
|
||||
.header-brand:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-brand .title-icon {
|
||||
font-size: 1.4rem;
|
||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(60, 130, 250, 0.35));
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.header-brand:hover .title-icon {
|
||||
transform: rotate(8deg) scale(1.08);
|
||||
filter: drop-shadow(0 0 16px rgba(60, 130, 250, 0.5));
|
||||
}
|
||||
|
||||
.header-brand .brand-text {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
margin-left: 1.5rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import LiquidGlassMenu from './LiquidGlassMenu'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import './FloatingHeader.css'
|
||||
|
||||
export default function FloatingHeader() {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', label: t('首页', 'Home') },
|
||||
{ key: '/compare', label: t('延迟对比', 'Compare') }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -21,10 +30,18 @@ export default function FloatingHeader() {
|
||||
|
||||
return (
|
||||
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
|
||||
<div className="header-title">
|
||||
<Link to="/" className="header-brand">
|
||||
<span className="title-icon">🌐</span>
|
||||
{t('延迟测试', 'Latency Test')}
|
||||
</div>
|
||||
<span className="brand-text">{t('延迟测试', 'Latency Test')}</span>
|
||||
</Link>
|
||||
|
||||
<LiquidGlassMenu
|
||||
items={menuItems}
|
||||
selectedKey={location.pathname}
|
||||
onSelect={(key) => navigate(key)}
|
||||
className="header-nav"
|
||||
/>
|
||||
|
||||
<div className="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
|
||||
49
src/client/components/HomePage.tsx
Normal file
49
src/client/components/HomePage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import IpInput from './IpInput'
|
||||
import LatencyMap from './LatencyMap'
|
||||
import ResultsPanel from './ResultsPanel'
|
||||
import { testAllNodes } from '../api/latency'
|
||||
import { LatencyResult } from '@shared/types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function HomePage() {
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleTest = useCallback(async (target: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
|
||||
await testAllNodes(target, (result) => {
|
||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||
})
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||
setTarget(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名(如 8.8.8.8 或 google.com)', 'Enter IP or domain (e.g., 8.8.8.8 or google.com)')}
|
||||
placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名', 'Enter IP or domain')}
|
||||
className={`ip-input ${error ? 'ip-input-error' : ''}`}
|
||||
disabled={testing || loading}
|
||||
/>
|
||||
|
||||
176
src/client/components/LiquidGlassMenu.css
Normal file
176
src/client/components/LiquidGlassMenu.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* Liquid Glass Menu - Premium iOS 26 Redesign */
|
||||
|
||||
.liquid-glass-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Premium Glass Indicator - Refined depth and refraction */
|
||||
.liquid-glass-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
/* Multi-layer frosted glass effect */
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.03) 40%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-radius: 999px;
|
||||
|
||||
/* Refined layered shadows for premium depth */
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
/* Inner top highlight - simulates rim light */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
/* Inner bottom edge - subtle thickness */
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
|
||||
/* Inner side highlights */
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
/* Soft ambient glow */
|
||||
0 8px 32px -8px rgba(0, 0, 0, 0.35),
|
||||
/* Contact shadow */
|
||||
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform, width;
|
||||
}
|
||||
|
||||
/* Top specular highlight - elegant gloss effect */
|
||||
.liquid-glass-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 38%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
}
|
||||
|
||||
/* Bottom subtle reflection */
|
||||
.liquid-glass-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 20%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.06) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Light Theme - Brighter, more solid glass */
|
||||
[data-theme="light"] .liquid-glass-indicator {
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.75) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.55) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.04),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
0 8px 24px -6px rgba(0, 0, 0, 0.15),
|
||||
0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::after {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Menu items container */
|
||||
.liquid-glass-items {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Individual menu item */
|
||||
.liquid-glass-item {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
transition: color 0.28s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
border-radius: 999px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liquid-glass-item:hover {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.liquid-glass-item.active {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Light Theme Text */
|
||||
[data-theme="light"] .liquid-glass-item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item:hover {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item.active {
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.liquid-glass-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
112
src/client/components/LiquidGlassMenu.tsx
Normal file
112
src/client/components/LiquidGlassMenu.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import './LiquidGlassMenu.css'
|
||||
|
||||
interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface LiquidGlassMenuProps {
|
||||
items: MenuItem[]
|
||||
selectedKey: string
|
||||
onSelect: (key: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LiquidGlassMenu({
|
||||
items,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
className = ''
|
||||
}: LiquidGlassMenuProps) {
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({
|
||||
transform: 'translateX(0)',
|
||||
width: 0,
|
||||
opacity: 0,
|
||||
transition: 'none'
|
||||
})
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const isInitialRender = useRef(true)
|
||||
|
||||
const selectedIndex = items.findIndex(item => item.key === selectedKey)
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
if (!containerRef.current || selectedIndex < 0) return
|
||||
|
||||
const targetItem = itemRefs.current[selectedIndex]
|
||||
if (!targetItem) return
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const itemRect = targetItem.getBoundingClientRect()
|
||||
const relativeLeft = itemRect.left - containerRect.left
|
||||
|
||||
setIndicatorStyle(prev => ({
|
||||
transform: `translateX(${relativeLeft}px)`,
|
||||
width: itemRect.width,
|
||||
opacity: 1,
|
||||
transition: isInitialRender.current
|
||||
? 'none'
|
||||
: prev.transition === 'none'
|
||||
? 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
: prev.transition
|
||||
}))
|
||||
|
||||
isInitialRender.current = false
|
||||
}, [selectedIndex])
|
||||
|
||||
// Update on mount, resize, and selection change
|
||||
useEffect(() => {
|
||||
updateIndicator()
|
||||
window.addEventListener('resize', updateIndicator)
|
||||
return () => window.removeEventListener('resize', updateIndicator)
|
||||
}, [updateIndicator])
|
||||
|
||||
// Watch for size changes in menu items (e.g., language switch)
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
|
||||
itemRefs.current.forEach(item => {
|
||||
if (item) observer.observe(item)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [updateIndicator, items.length])
|
||||
|
||||
// Re-calculate when items labels change (language switch)
|
||||
useEffect(() => {
|
||||
// Small delay to ensure DOM has updated
|
||||
const timer = setTimeout(updateIndicator, 10)
|
||||
return () => clearTimeout(timer)
|
||||
}, [items.map(i => i.label).join(','), updateIndicator])
|
||||
|
||||
return (
|
||||
<div className={`liquid-glass-menu ${className}`} ref={containerRef}>
|
||||
<div
|
||||
className="liquid-glass-indicator"
|
||||
style={{
|
||||
transform: indicatorStyle.transform,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
opacity: indicatorStyle.opacity,
|
||||
transition: indicatorStyle.transition
|
||||
}}
|
||||
/>
|
||||
<div className="liquid-glass-items">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={el => { itemRefs.current[index] = el }}
|
||||
className={`liquid-glass-item ${item.key === selectedKey ? 'active' : ''}`}
|
||||
onClick={() => onSelect(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const TEST_NODES: TestNode[] = [
|
||||
{ id: 'us-east', name: 'New York', region: 'North America', coords: [-74.006, 40.7128], country: 'US', city: 'New York' },
|
||||
{ id: 'us-central', name: 'Dallas', region: 'North America', coords: [-96.797, 32.7767], country: 'US', city: 'Dallas' },
|
||||
{ id: 'canada', name: 'Toronto', region: 'North America', coords: [-79.3832, 43.6532], country: 'CA', city: 'Toronto' },
|
||||
{ id: 'mexico', name: 'Mexico City', region: 'North America', coords: [-99.1332, 19.4326], country: 'MX', city: 'Mexico City' },
|
||||
{ id: 'mexico', name: 'Queretaro', region: 'North America', coords: [-100.39, 20.59], country: 'MX', city: 'Queretaro' },
|
||||
|
||||
// Europe - Western
|
||||
{ id: 'eu-west', name: 'London', region: 'Europe', coords: [-0.1276, 51.5074], country: 'GB', city: 'London' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TEST_NODES } from '../shared/types'
|
||||
|
||||
interface Env {
|
||||
GLOBALPING_API: string
|
||||
ASSETS?: { fetch: (request: Request) => Promise<Response> }
|
||||
}
|
||||
|
||||
interface MeasurementResponse {
|
||||
@@ -244,7 +245,8 @@ export default {
|
||||
return handleGetMeasurement(batchMatch[1], env)
|
||||
}
|
||||
|
||||
// Serve static files for other routes (handled by Cloudflare Pages/Workers Sites)
|
||||
return new Response('Not Found', { status: 404 })
|
||||
// For non-API routes, let Cloudflare Assets handle static files
|
||||
// This will be caught by the assets handler configured in wrangler.toml
|
||||
return env.ASSETS ? env.ASSETS.fetch(request) : new Response('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
// SPA fallback middleware - runs after Vite's built-in middleware
|
||||
function spaFallback() {
|
||||
return {
|
||||
name: 'spa-fallback',
|
||||
configureServer: function (server) {
|
||||
// Return a function to execute after Vite's internal middleware
|
||||
return function () {
|
||||
server.middlewares.use(function (req, res, next) {
|
||||
var _a, _b;
|
||||
// Skip API routes and file requests
|
||||
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
|
||||
return next();
|
||||
}
|
||||
// Serve index.html for SPA routes
|
||||
var indexPath = path.resolve(__dirname, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
var html = fs.readFileSync(indexPath, 'utf-8');
|
||||
server.transformIndexHtml(req.url || '/', html).then(function (transformed) {
|
||||
res.end(transformed);
|
||||
}).catch(next);
|
||||
}
|
||||
else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
configurePreviewServer: function (server) {
|
||||
// Same logic for preview server
|
||||
server.middlewares.use(function (req, res, next) {
|
||||
var _a, _b;
|
||||
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
|
||||
return next();
|
||||
}
|
||||
var indexPath = path.resolve(__dirname, 'dist/client/index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(fs.readFileSync(indexPath, 'utf-8'));
|
||||
}
|
||||
else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), spaFallback()],
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
@@ -16,6 +67,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/client'),
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type PluginOption } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// SPA fallback middleware - runs after Vite's built-in middleware
|
||||
function spaFallback(): PluginOption {
|
||||
return {
|
||||
name: 'spa-fallback',
|
||||
configureServer(server) {
|
||||
// Return a function to execute after Vite's internal middleware
|
||||
return () => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// Skip API routes and file requests
|
||||
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
const indexPath = path.resolve(__dirname, 'index.html')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
let html = fs.readFileSync(indexPath, 'utf-8')
|
||||
server.transformIndexHtml(req.url || '/', html).then(transformed => {
|
||||
res.end(transformed)
|
||||
}).catch(next)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
// Same logic for preview server
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const indexPath = path.resolve(__dirname, 'dist/client/index.html')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(fs.readFileSync(indexPath, 'utf-8'))
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), spaFallback()],
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
@@ -17,6 +67,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/client'),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
name = "latency-test"
|
||||
main = "dist/worker/index.js"
|
||||
compatibility_date = "2025-12-22"
|
||||
compatibility_date = "2025-12-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
assets = { directory = "./dist/client" }
|
||||
[assets]
|
||||
directory = "./dist/client"
|
||||
html_handling = "auto-trailing-slash"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[vars]
|
||||
GLOBALPING_API = "https://api.globalping.io/v1"
|
||||
|
||||
Reference in New Issue
Block a user