diff --git a/.gitignore b/.gitignore index cfca701..091a150 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,5 @@ dist .claude .idea -.wrangler \ No newline at end of file +.wrangler +.kiro \ No newline at end of file diff --git a/src/client/api/latency.ts b/src/client/api/latency.ts index 8bd24ac..8897436 100644 --- a/src/client/api/latency.ts +++ b/src/client/api/latency.ts @@ -86,8 +86,10 @@ export interface TestResult { export async function testAllNodes( target: string, - onProgress: (result: LatencyResult) => void + onProgress: (result: LatencyResult) => void, + onPhase?: (phase: 'init' | 'testing' | 'traceroute') => void ): Promise { + onPhase?.('init') for (const node of TEST_NODES) { onProgress({ nodeId: node.id, latency: null, status: 'pending' }) } @@ -107,6 +109,7 @@ export async function testAllNodes( const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json() + onPhase?.('testing') for (const node of TEST_NODES) { onProgress({ nodeId: node.id, latency: null, status: 'testing' }) } @@ -165,6 +168,7 @@ export async function testAllNodes( // Fetch traceroute results if available let traceroute: TracerouteStats | null = null if (tracerouteId) { + onPhase?.('traceroute') try { const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`) if (trRes.ok) { diff --git a/src/client/components/ComparePage.css b/src/client/components/ComparePage.css index 319cea0..640a307 100644 --- a/src/client/components/ComparePage.css +++ b/src/client/components/ComparePage.css @@ -1,18 +1,38 @@ .compare-page { - max-width: 1000px; + max-width: 1440px; margin: 0 auto; width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} + +/* Hero Card - unified container for header, inputs, and progress */ +.compare-page .hero-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + width: 100%; + max-width: 1200px; + padding: 3rem; + background: var(--card-bg); + border-radius: 2rem; + border: 1px solid var(--border-color); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); } .compare-header { text-align: center; - margin-bottom: 2.5rem; } .compare-title { - font-size: 1.75rem; + font-size: 2rem; font-weight: 800; - margin-bottom: 0.75rem; + margin-bottom: 0.5rem; background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; @@ -24,24 +44,50 @@ 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)); +.compare-inputs-wrapper { + display: flex; + align-items: flex-end; + gap: 2rem; + width: 100%; + max-width: 900px; } .input-group { display: flex; flex-direction: column; gap: 0.5rem; + flex: 1; +} + +.input-vs-divider { + font-weight: 800; + font-size: 1.5rem; + color: var(--primary-color); + padding-bottom: 0.75rem; + text-shadow: 0 0 20px rgba(59, 130, 246, 0.3); + position: relative; +} + +.input-vs-divider::before, +.input-vs-divider::after { + content: ''; + position: absolute; + top: 50%; + width: 2rem; + height: 2px; + background: linear-gradient(90deg, transparent, var(--primary-color)); + opacity: 0.3; +} + +.input-vs-divider::before { + right: 100%; + margin-right: 0.75rem; +} + +.input-vs-divider::after { + left: 100%; + margin-left: 0.75rem; + background: linear-gradient(90deg, var(--primary-color), transparent); } .input-label { @@ -90,19 +136,14 @@ 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; + gap: 0.75rem; + padding: 1rem 3rem; + margin-top: 1rem; + border-radius: 1rem; border: none; background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); color: #fff; @@ -161,7 +202,7 @@ /* Results Table */ .results-container { background: var(--card-bg); - border-radius: 1rem; + border-radius: 1.5rem; border: 1px solid var(--border-color); overflow: hidden; backdrop-filter: var(--glass-blur); @@ -176,7 +217,7 @@ .results-table th, .results-table td { - padding: 0.875rem 1.25rem; + padding: 1rem 1.5rem; text-align: left; border-bottom: 1px solid var(--border-color); } @@ -379,12 +420,37 @@ flex: 1; } -/* Share link card container */ -.compare-share-container { - margin-top: -0.5rem; - margin-bottom: 2rem; +/* Comparison Stats Grid - two column layout for A vs B */ +.comparison-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; width: 100%; - animation: slideUp 0.3s ease-out; +} + +.comparison-column { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.comparison-column-header { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-color); + text-align: center; + padding: 0.75rem 1rem; + background: var(--card-bg); + border-radius: 1rem; + border: 1px solid var(--border-color); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + word-break: break-all; +} + +/* Share link card - match hero-card width */ +.compare-page .share-link-card { + width: 100%; + max-width: 1200px; } @keyframes slideUp { @@ -399,11 +465,28 @@ } @media (max-width: 768px) { - .compare-inputs { - grid-template-columns: 1fr; + .hero-card { + padding: 1.5rem; + border-radius: 1rem; + } + + .compare-inputs-wrapper { + flex-direction: column; + align-items: stretch; gap: 1rem; } + .input-vs-divider { + align-self: center; + padding-bottom: 0; + margin: 0.5rem 0; + } + + .input-vs-divider::before, + .input-vs-divider::after { + display: none; + } + .compare-inputs.readonly { flex-direction: column; gap: 1.5rem; @@ -415,6 +498,10 @@ margin: -0.5rem 0; } + .comparison-stats-grid { + grid-template-columns: 1fr; + } + .compare-ip-info-cards, .compare-score-cards { grid-template-columns: 1fr; diff --git a/src/client/components/ComparePage.tsx b/src/client/components/ComparePage.tsx index 4f3c301..31ef247 100644 --- a/src/client/components/ComparePage.tsx +++ b/src/client/components/ComparePage.tsx @@ -8,14 +8,31 @@ import ExpirationBanner from './ExpirationBanner' import ShareLinkCard from './ShareLinkCard' import IpInfoCard from './IpInfoCard' import ScoreCard from './ScoreCard' +import TestProgressBar, { TestPhase } from './TestProgressBar' 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,}$/ +const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ + +function normalizeTarget(value: string): string { + let target = value.trim().toLowerCase() + // Remove protocol prefix if present + target = target.replace(/^https?:\/\//, '') + // Strip userinfo if a full URL with credentials was pasted + const atIndex = target.lastIndexOf('@') + if (atIndex !== -1) { + target = target.slice(atIndex + 1) + } + // Remove path, query, and fragment + target = target.split(/[/?#]/)[0] + // Remove port if present + target = target.split(':')[0] + return target +} function isValidTarget(value: string): boolean { - const trimmed = value.trim().toLowerCase() - return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed) + const normalized = normalizeTarget(value) + return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized) } export default function ComparePage() { @@ -39,6 +56,7 @@ export default function ComparePage() { const [ipInfoB, setIpInfoB] = useState(null) const [tracerouteA, setTracerouteA] = useState(null) const [tracerouteB, setTracerouteB] = useState(null) + const [phase, setPhase] = useState('idle') useEffect(() => { if (!resultId) { @@ -126,10 +144,10 @@ export default function ComparePage() { const handleCompare = async () => { if (isReadOnly) return - const trimmedA = targetA.trim() - const trimmedB = targetB.trim() - const aValid = isValidTarget(trimmedA) - const bValid = isValidTarget(trimmedB) + const normalizedA = normalizeTarget(targetA) + const normalizedB = normalizeTarget(targetB) + const aValid = isValidTarget(targetA) + const bValid = isValidTarget(targetB) setErrors({ a: aValid ? '' : t('无效的目标', 'Invalid target'), @@ -138,7 +156,12 @@ export default function ComparePage() { if (!aValid || !bValid) return + // Update display to show normalized values + setTargetA(normalizedA) + setTargetB(normalizedB) + setTesting(true) + setPhase('init') setResultsA(new Map()) setResultsB(new Map()) setShareUrl(null) @@ -153,8 +176,10 @@ export default function ComparePage() { const rightResults = new Map() try { - const [resA, resB] = await Promise.all([ - testAllNodes(trimmedA, (res) => { + // Test targets sequentially to avoid GlobalPing API rate limits + const resA = await testAllNodes( + normalizedA, + (res) => { setResultsA(prev => new Map(prev).set(res.nodeId, res)) if (res.status === 'success' || res.status === 'failed') { leftResults.set(res.nodeId, { @@ -164,8 +189,16 @@ export default function ComparePage() { stats: res.stats }) } - }), - testAllNodes(trimmedB, (res) => { + }, + (newPhase) => { + if (newPhase === 'testing') setPhase('testing') + else if (newPhase === 'traceroute') setPhase('traceroute') + } + ) + + const resB = await testAllNodes( + normalizedB, + (res) => { setResultsB(prev => new Map(prev).set(res.nodeId, res)) if (res.status === 'success' || res.status === 'failed') { rightResults.set(res.nodeId, { @@ -175,8 +208,8 @@ export default function ComparePage() { stats: res.stats }) } - }) - ]) + } + ) if (resA.resolvedAddress) { setResolvedIpA(resA.resolvedAddress) @@ -206,22 +239,29 @@ export default function ComparePage() { }) try { + setPhase('saving') const { shareUrl: url } = await saveResult({ type: 'compare', - input: { leftTarget: trimmedA, rightTarget: trimmedB }, + input: { leftTarget: normalizedA, rightTarget: normalizedB }, results: { left, right }, ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null }, traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null } }) setShareUrl(url) setShowShareModal(true) + setPhase('complete') } catch (e) { console.error('Failed to save result:', e) + setPhase('complete') } } catch (e) { console.error('Comparison test failed:', e) + setPhase('complete') } finally { - setTesting(false) + setTimeout(() => { + setTesting(false) + setPhase('idle') + }, 1500) } } @@ -282,60 +322,68 @@ export default function ComparePage() { return (
-
-

{t('延迟对比', 'Latency Comparison')}

-

- {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')} -

-
- {isReadOnly && } {isReadOnly ? ( -
-
- {t('目标 A', 'Target A')} - {targetA} - {ipInfoA && } - {resultsA.size > 0 && } + <> +
+

{t('延迟对比', 'Latency Comparison')}

+

+ {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')} +

-
VS
-
- {t('目标 B', 'Target B')} - {targetB} - {ipInfoB && } - {resultsB.size > 0 && } +
{t('只读模式', 'View Only')}
+
+
+
{targetA}
+ {resultsA.size > 0 && } + {ipInfoA && } +
+
+
{targetB}
+ {resultsB.size > 0 && } + {ipInfoB && } +
-
{t('只读模式', 'View Only')}
-
+ ) : ( <> -
-
- - { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }} - placeholder={t('输入IP或域名', 'Enter IP or domain')} - disabled={testing} - /> - {errors.a && {errors.a}} +
+
+

{t('延迟对比', 'Latency Comparison')}

+

+ {t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')} +

+
+ +
+
+ + { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }} + placeholder={t('输入IP或域名', 'Enter IP or domain')} + disabled={testing} + /> + {errors.a && {errors.a}} +
+ +
VS
+ +
+ + { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }} + placeholder={t('输入IP或域名', 'Enter IP or domain')} + disabled={testing} + /> + {errors.b && {errors.b}} +
-
- - { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }} - placeholder={t('输入IP或域名', 'Enter IP or domain')} - disabled={testing} - /> - {errors.b && {errors.b}} -
-
-
- {(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && ( -
- {(ipInfoA || ipInfoB) && ( -
- {ipInfoA && } - {ipInfoB && } -
- )} - {(resultsA.size > 0 || resultsB.size > 0) && ( -
- {resultsA.size > 0 && } - {resultsB.size > 0 && } -
- )} - {shareUrl && } + {(resultsA.size > 0 || resultsB.size > 0) && !testing && ( +
+
+
{targetA || 'Target A'}
+ {resultsA.size > 0 && } + {ipInfoA && } +
+
+
{targetB || 'Target B'}
+ {resultsB.size > 0 && } + {ipInfoB && } +
)} + + {shareUrl && !testing && } )} diff --git a/src/client/components/HomePage.tsx b/src/client/components/HomePage.tsx index 89eb118..35a09fc 100644 --- a/src/client/components/HomePage.tsx +++ b/src/client/components/HomePage.tsx @@ -8,8 +8,9 @@ import ExpirationBanner from './ExpirationBanner' import ShareLinkCard from './ShareLinkCard' import IpInfoCard from './IpInfoCard' import ScoreCard from './ScoreCard' +import TestProgressBar, { TestPhase } from './TestProgressBar' import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency' -import { LatencyResult, IpInfo, TracerouteStats } from '@shared/types' +import { LatencyResult, IpInfo, TracerouteStats, TEST_NODES } from '@shared/types' import { useLanguage } from '../contexts/LanguageContext' export default function HomePage() { @@ -27,6 +28,7 @@ export default function HomePage() { const [resolvedIp, setResolvedIp] = useState(null) const [ipInfo, setIpInfo] = useState(null) const [traceroute, setTraceroute] = useState(null) + const [phase, setPhase] = useState('idle') const { t } = useLanguage() // Load saved result if viewing shared link @@ -77,6 +79,7 @@ export default function HomePage() { const handleTest = useCallback(async (testTarget: string) => { setTesting(true) + setPhase('init') setResults(new Map()) setSelectedNodeId(null) setShareUrl(null) @@ -87,17 +90,21 @@ export default function HomePage() { const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = [] - const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(testTarget, (result) => { - setResults((prev) => new Map(prev).set(result.nodeId, result)) - if (result.status === 'success' || result.status === 'failed') { - finalResults.push({ - nodeId: result.nodeId, - latency: result.latency, - success: result.status === 'success', - stats: result.stats - }) - } - }) + const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes( + testTarget, + (result) => { + setResults((prev) => new Map(prev).set(result.nodeId, result)) + if (result.status === 'success' || result.status === 'failed') { + finalResults.push({ + nodeId: result.nodeId, + latency: result.latency, + success: result.status === 'success', + stats: result.stats + }) + } + }, + (newPhase) => setPhase(newPhase) + ) if (resolvedAddress) { setResolvedIp(resolvedAddress) @@ -113,6 +120,7 @@ export default function HomePage() { // Save result and get share URL try { + setPhase('saving') const { shareUrl: url } = await saveResult({ type: 'single', input: { target: testTarget }, @@ -122,11 +130,16 @@ export default function HomePage() { }) setShareUrl(url) setShowShareModal(true) + setPhase('complete') } catch (e) { console.error('Failed to save result:', e) + setPhase('complete') } - setTesting(false) + setTimeout(() => { + setTesting(false) + setPhase('idle') + }, 1500) }, []) const handleNodeSelect = useCallback((nodeId: string | null) => { @@ -152,54 +165,90 @@ export default function HomePage() { const currentUrl = isReadOnly ? window.location.href : undefined + const hasResults = results.size > 0 || ipInfo || shareUrl + const hasScoreCard = results.size > 0 + const hasInfoCard = !!ipInfo || (!!resolvedIp && target !== resolvedIp) + const hasTopRow = hasScoreCard || hasInfoCard + return ( <> -

- {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')} -

- {isReadOnly && } - {isReadOnly ? ( -
-
- {t('测试目标', 'Test Target')} - {target} -
- {ipInfo && } - {results.size > 0 && } -
{t('只读模式', 'View Only')}
-
- ) : ( - <> - - {(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && ( -
- {ipInfo ? ( - - ) : resolvedIp && target !== resolvedIp ? ( -
- {t('解析IP', 'Resolved IP')} - {resolvedIp} -
- ) : null} - {results.size > 0 && } - {shareUrl && } -
- )} - - )} +
+

+ {t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')} +

- - + {isReadOnly ? ( +
+
+ {t('测试目标', 'Test Target')} + {target} +
+
{t('只读模式', 'View Only')}
+
+ ) : ( + <> + + r.status === 'success' || r.status === 'failed').length} + total={TEST_NODES.length} + phase={phase} + /> + + )} +
+ +
+ {/* Map Section - The Visual Hero */} +
+ +
+ + {/* Stats Grid - Cards below map */} + {hasResults && ( +
+ {hasTopRow && ( +
+ {hasScoreCard && ( +
+ +
+ )} + {hasInfoCard && ( +
+ {ipInfo ? ( + + ) : ( +
+ {t('解析IP', 'Resolved IP')} + {resolvedIp} +
+ )} +
+ )} +
+ )} + {shareUrl && } +
+ )} + + {/* Results Table */} + +
{ e.preventDefault() - const trimmed = target.trim() - if (!isValidTarget(trimmed)) { + if (!isValidTarget(target)) { setError(t('无效的IP地址或域名', 'Invalid IP address or domain')) return } + const normalized = normalizeTarget(target) + setTarget(normalized) // Update display to show normalized value setError('') - onTest(trimmed) + onTest(normalized) } return ( diff --git a/src/client/components/LatencyMap.css b/src/client/components/LatencyMap.css index 9102643..a26686c 100644 --- a/src/client/components/LatencyMap.css +++ b/src/client/components/LatencyMap.css @@ -1,9 +1,8 @@ .map-container { position: relative; width: 100%; - max-width: 1000px; - height: 500px; - margin: 0 auto 3rem; + height: 100%; + min-height: 500px; border-radius: 16px; overflow: hidden; background: #000; @@ -152,6 +151,7 @@ @media (max-width: 768px) { .map-container { height: 400px; + min-height: 400px; } .globe-popup { diff --git a/src/client/components/LatencyMap.tsx b/src/client/components/LatencyMap.tsx index eec1f00..e42109b 100644 --- a/src/client/components/LatencyMap.tsx +++ b/src/client/components/LatencyMap.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from 'react' +import { useMemo, useEffect, useRef, useState } from 'react' import Globe, { GlobeMethods } from 'react-globe.gl' import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types' import { useLanguage } from '../contexts/LanguageContext' @@ -14,6 +14,27 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La const globeEl = useRef(undefined) const containerRef = useRef(null) const { t } = useLanguage() + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) + + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + setDimensions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight + }) + } + } + + updateDimensions() + const ro = new ResizeObserver(updateDimensions) + + if (containerRef.current) { + ro.observe(containerRef.current) + } + + return () => ro.disconnect() + }, []) useEffect(() => { if (globeEl.current) { @@ -73,6 +94,8 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
0 ? current / total : 0 + percentage = 5 + testingProgress * 80 + label = t(`正在测试节点 (${current}/${total})`, `Testing nodes (${current}/${total})`) + break + case 'traceroute': + percentage = 90 + label = t('分析路由路径...', 'Analyzing routes...') + break + case 'saving': + percentage = 95 + label = t('保存结果...', 'Saving results...') + break + case 'complete': + percentage = 100 + label = t('测试完成', 'Test Complete') + break + } + + percentage = Math.min(Math.round(percentage), 100) + + if (phase === 'idle') return null + + return ( +
+
+ {label} + {percentage}% +
+ +
+
+
+
+ +
+
= 5 ? 'active' : ''}`} style={{ left: '5%' }} /> +
= 85 ? 'active' : ''}`} style={{ left: '85%' }} /> +
= 95 ? 'active' : ''}`} style={{ left: '95%' }} /> +
+
+ +
+ {t('初始化', 'Init')} + {t('测试', 'Test')} + {t('路由', 'Trace')} + {t('完成', 'Done')} +
+
+ ) +} diff --git a/src/client/styles/index.css b/src/client/styles/index.css index 2dc5d47..7e082f0 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -117,8 +117,8 @@ body { .app-main { flex: 1; - padding: 3rem 2rem; - max-width: 1200px; + padding: 2rem; + max-width: 1000px; margin: 0 auto; width: 100%; } @@ -206,6 +206,41 @@ body { to { opacity: 1; transform: translateY(0); } } +/* Hero Card Section */ +.hero-card { + background: var(--card-bg); + border-radius: 2rem; + padding: 3rem 2rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + width: 100%; + max-width: 1000px; + margin-left: auto; + margin-right: auto; +} + +.hero-card .app-description { + margin-bottom: 0; +} + +.hero-card .ip-input-form { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .hero-card { + padding: 1.5rem 1rem; + gap: 1rem; + } +} + /* Loading and Error States */ .loading-container { display: flex; @@ -330,3 +365,149 @@ body { flex-direction: column; } } + +/* --- New Layout System --- */ + +/* Main layout container - vertical stack for breathing room */ +.layout-container { + display: flex; + flex-direction: column; + gap: 2rem; + width: 100%; +} + +/* Map Section - The Visual Hero/Focus */ +.map-section { + width: 100%; + height: 55vh; + min-height: 450px; + max-height: 650px; + background: var(--card-bg); + border-radius: 1.5rem; + border: 1px solid var(--border-color); + box-shadow: 0 8px 32px -4px rgba(0, 0, 0, 0.08); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; +} + +/* Stats Grid - Cards arranged below map */ +.stats-grid { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; + max-width: 1000px; + margin: 0 auto; +} + +/* Top row: ScoreCard + IpInfoCard side by side */ +.stats-top-row { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +} + +.stats-grid > * { + margin-bottom: 0 !important; +} + +.stats-score-col > *, +.stats-info-col > * { + margin-bottom: 0 !important; +} + +/* Desktop: side by side layout */ +@media (min-width: 850px) { + .stats-top-row { + flex-direction: row; + align-items: flex-start; + } + + .stats-score-col { + flex: 1.6; + width: 0; + min-width: 0; + } + + .stats-info-col { + flex: 1; + min-width: 300px; + } +} + +/* ShareLinkCard full width */ +.stats-grid .share-link-card { + width: 100%; + margin-bottom: 0; +} + +/* Refined Resolved IP Card */ +.stats-grid .resolved-ip-info { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1.5rem; + background: var(--card-bg); + border-radius: 1rem; + border: 1px solid var(--border-color); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + margin-bottom: 0; + width: 100%; +} + +/* Mobile Adjustments */ +@media (max-width: 768px) { + .map-section { + height: 45vh; + min-height: 350px; + max-height: 450px; + border-radius: 1rem; + } + + .layout-container { + gap: 1.25rem; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: 1rem; + } +} + +/* --- Comparison Grid Layout --- */ +.comparison-stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; + margin-bottom: 2rem; + align-items: start; +} + +@media (min-width: 768px) { + .comparison-stats-grid { + grid-template-columns: 1fr 1fr; + } +} + +.comparison-column { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.comparison-column-header { + text-align: center; + font-weight: 700; + font-size: 1.1rem; + color: var(--primary-color); + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); + font-family: 'JetBrains Mono', monospace; + word-break: break-all; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index d93dc5d..ac91324 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -148,10 +148,27 @@ function generateId(): string { } 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,}$/ +const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ + +function normalizeTarget(value: string): string { + let target = value.trim().toLowerCase() + // Remove protocol prefix if present + target = target.replace(/^https?:\/\//, '') + // Strip userinfo if a full URL with credentials was pasted + const atIndex = target.lastIndexOf('@') + if (atIndex !== -1) { + target = target.slice(atIndex + 1) + } + // Remove path, query, and fragment + target = target.split(/[/?#]/)[0] + // Remove port if present + target = target.split(':')[0] + return target +} function isValidTarget(target: string): boolean { - return IP_REGEX.test(target) || DOMAIN_REGEX.test(target) + const normalized = normalizeTarget(target) + return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized) } function isIPAddress(target: string): boolean { @@ -536,16 +553,17 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise