From 7e66beed5f7e6a72d039794085faa63a8380783e Mon Sep 17 00:00:00 2001 From: bokiko Date: Fri, 20 Mar 2026 00:13:12 +0000 Subject: [PATCH 1/3] docs: update IMPROVEMENTS.md for 2026-03-20 accessibility --- IMPROVEMENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 7e53510..4319ee4 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,5 +1,18 @@ # PingDiff Improvement Log +## 2026-03-20 — Accessibility: ARIA labels, table semantics, and skip navigation + +Comprehensive a11y pass across the dashboard, navbar, and secondary pages. The site had no named navigation landmark, no skip links on 3 of 4 pages, tables without column scope attributes, charts completely invisible to assistive technology, and stat cards that conveyed quality purely through color (WCAG 1.4.1 violation). All fixed without new dependencies. + +Dashboard: skip link + main-content anchor, role="region" on stats grid, aria-label on each stat card with text description, aria-hidden on decorative icons, role="img" + aria-label on both charts, aria-label on table element, scope="col" on all th elements, time element for timestamps, aria-label on ping/loss cells so quality is communicated in text not just color. + +Navbar: aria-label="Main navigation" on nav element, aria-haspopup on mobile toggle, role="menu" on mobile menu container. + +Community and Download pages: both were missing skip-to-content links and main-content anchor targets entirely. + +**Files changed:** `web/src/app/dashboard/page.tsx`, `web/src/components/Navbar.tsx`, `web/src/app/community/page.tsx`, `web/src/app/download/page.tsx` +**Lines:** +64 / -30 + ## 2026-03-19 — Performance: Memoize dashboard derived state All derived values on the dashboard (filteredResults, avgPing, avgPacketLoss, avgJitter, regions, chartData, serverChartData) were being recomputed inline on every React render — including renders triggered by unrelated state changes like the loading flag toggling off. Wrapped each value in useMemo with the tightest possible dependency array, eliminating 5 O(n) reduce passes and 2 groupBy passes on every extraneous render. At current scale the savings are modest; at the 500-1000 result range the dashboard would hit without this change the difference is measurable. The memoized structure also makes data dependencies explicit and auditable at a glance. From aa08deb836999e32cf6a815c7d0162c426c3ed55 Mon Sep 17 00:00:00 2001 From: bokiko Date: Fri, 20 Mar 2026 06:13:36 +0000 Subject: [PATCH 2/3] fix: add per-page metadata to dashboard, download, and community pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three pages used 'use client' at the top level, which prevents Next.js from exporting the metadata object. As a result, every page shared the same generic homepage title and description — the layout.tsx template ('%s | PingDiff') was dead code for these routes. Fix: split each page into a thin server wrapper (exports metadata) and a client component (holds all interactive state). This is the standard App Router pattern for mixing metadata with client-side state. Each page now has a distinct and <meta description>: /dashboard → 'Dashboard | PingDiff' /download → 'Download | PingDiff' /community → 'Community | PingDiff' Also adds per-page og:title and og:description so social shares (Twitter, Discord, iMessage) show accurate previews instead of the generic homepage copy. Build verified clean (no TypeScript or ESLint errors). --- web/src/app/community/CommunityClient.tsx | 83 ++++ web/src/app/community/page.tsx | 92 +--- web/src/app/dashboard/DashboardClient.tsx | 547 ++++++++++++++++++++ web/src/app/dashboard/page.tsx | 576 +--------------------- web/src/app/download/DownloadClient.tsx | 268 ++++++++++ web/src/app/download/page.tsx | 278 +---------- 6 files changed, 934 insertions(+), 910 deletions(-) create mode 100644 web/src/app/community/CommunityClient.tsx create mode 100644 web/src/app/dashboard/DashboardClient.tsx create mode 100644 web/src/app/download/DownloadClient.tsx diff --git a/web/src/app/community/CommunityClient.tsx b/web/src/app/community/CommunityClient.tsx new file mode 100644 index 0000000..d2d5d54 --- /dev/null +++ b/web/src/app/community/CommunityClient.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { MessageSquare, ThumbsUp, Users, Construction } from "lucide-react"; +import { Navbar } from "@/components/Navbar"; +import { Footer } from "@/components/Footer"; + +export default function CommunityClient() { + return ( + <div className="min-h-screen"> + <a href="#main-content" className="skip-to-content focus-ring"> + Skip to main content + </a> + <Navbar /> + + <main id="main-content" className="max-w-6xl mx-auto px-4 py-16"> + {/* Coming Soon Banner */} + <div className="text-center mb-16"> + <div className="inline-flex items-center justify-center w-20 h-20 bg-yellow-500/20 rounded-2xl mb-6"> + <Construction className="w-10 h-10 text-yellow-500" /> + </div> + <h1 className="text-4xl font-bold mb-4">Community Hub</h1> + <p className="text-zinc-400 text-lg max-w-xl mx-auto"> + Share tips, compare results, and help other players find the best servers. + </p> + <div className="mt-6 inline-flex items-center gap-2 bg-yellow-500/10 border border-yellow-500/20 rounded-full px-4 py-2"> + <span className="text-yellow-400 text-sm font-medium">Coming Soon</span> + </div> + </div> + + {/* Preview Features */} + <div className="grid md:grid-cols-3 gap-6 mb-16"> + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> + <div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center mb-4"> + <MessageSquare className="w-6 h-6 text-blue-500" /> + </div> + <h3 className="text-xl font-semibold mb-2">ISP Tips</h3> + <p className="text-zinc-400"> + Share and discover tips for optimizing your connection based on your ISP and region. + </p> + </div> + + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> + <div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4"> + <ThumbsUp className="w-6 h-6 text-green-500" /> + </div> + <h3 className="text-xl font-semibold mb-2">Upvote System</h3> + <p className="text-zinc-400"> + Vote on the most helpful tips to surface the best advice for each region. + </p> + </div> + + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> + <div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center mb-4"> + <Users className="w-6 h-6 text-purple-500" /> + </div> + <h3 className="text-xl font-semibold mb-2">Leaderboards</h3> + <p className="text-zinc-400"> + See the best ping results by region, ISP, and server location. + </p> + </div> + </div> + + {/* CTA */} + <div className="text-center bg-zinc-900/50 border border-zinc-800 rounded-2xl p-8"> + <h2 className="text-2xl font-bold mb-4">Want to be notified when Community launches?</h2> + <p className="text-zinc-400 mb-6"> + Star our GitHub repo to get updates on new features. + </p> + <a + href="https://github.com/bokiko/pingdiff" + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-2 btn-primary px-6 py-3 rounded-xl font-medium" + > + Star on GitHub + </a> + </div> + </main> + + <Footer /> + </div> + ); +} diff --git a/web/src/app/community/page.tsx b/web/src/app/community/page.tsx index cd6b1ca..b7105b7 100644 --- a/web/src/app/community/page.tsx +++ b/web/src/app/community/page.tsx @@ -1,83 +1,15 @@ -"use client"; - -import { MessageSquare, ThumbsUp, Users, Construction } from "lucide-react"; -import { Navbar } from "@/components/Navbar"; -import { Footer } from "@/components/Footer"; +import type { Metadata } from "next"; +import CommunityClient from "./CommunityClient"; + +export const metadata: Metadata = { + title: "Community", + description: "Share connection tips, compare ping results with other players, and find the best game servers for your region.", + openGraph: { + title: "PingDiff Community Hub", + description: "Share connection tips, compare ping results with other players, and find the best game servers for your region.", + }, +}; export default function CommunityPage() { - return ( - <div className="min-h-screen"> - <a href="#main-content" className="skip-to-content focus-ring"> - Skip to main content - </a> - <Navbar /> - - <main id="main-content" className="max-w-6xl mx-auto px-4 py-16"> - {/* Coming Soon Banner */} - <div className="text-center mb-16"> - <div className="inline-flex items-center justify-center w-20 h-20 bg-yellow-500/20 rounded-2xl mb-6"> - <Construction className="w-10 h-10 text-yellow-500" /> - </div> - <h1 className="text-4xl font-bold mb-4">Community Hub</h1> - <p className="text-zinc-400 text-lg max-w-xl mx-auto"> - Share tips, compare results, and help other players find the best servers. - </p> - <div className="mt-6 inline-flex items-center gap-2 bg-yellow-500/10 border border-yellow-500/20 rounded-full px-4 py-2"> - <span className="text-yellow-400 text-sm font-medium">Coming Soon</span> - </div> - </div> - - {/* Preview Features */} - <div className="grid md:grid-cols-3 gap-6 mb-16"> - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> - <div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center mb-4"> - <MessageSquare className="w-6 h-6 text-blue-500" /> - </div> - <h3 className="text-xl font-semibold mb-2">ISP Tips</h3> - <p className="text-zinc-400"> - Share and discover tips for optimizing your connection based on your ISP and region. - </p> - </div> - - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> - <div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4"> - <ThumbsUp className="w-6 h-6 text-green-500" /> - </div> - <h3 className="text-xl font-semibold mb-2">Upvote System</h3> - <p className="text-zinc-400"> - Vote on the most helpful tips to surface the best advice for each region. - </p> - </div> - - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 opacity-60"> - <div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center mb-4"> - <Users className="w-6 h-6 text-purple-500" /> - </div> - <h3 className="text-xl font-semibold mb-2">Leaderboards</h3> - <p className="text-zinc-400"> - See the best ping results by region, ISP, and server location. - </p> - </div> - </div> - - {/* CTA */} - <div className="text-center bg-zinc-900/50 border border-zinc-800 rounded-2xl p-8"> - <h2 className="text-2xl font-bold mb-4">Want to be notified when Community launches?</h2> - <p className="text-zinc-400 mb-6"> - Star our GitHub repo to get updates on new features. - </p> - <a - href="https://github.com/bokiko/pingdiff" - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-2 btn-primary px-6 py-3 rounded-xl font-medium" - > - Star on GitHub - </a> - </div> - </main> - - <Footer /> - </div> - ); + return <CommunityClient />; } diff --git a/web/src/app/dashboard/DashboardClient.tsx b/web/src/app/dashboard/DashboardClient.tsx new file mode 100644 index 0000000..b75ef54 --- /dev/null +++ b/web/src/app/dashboard/DashboardClient.tsx @@ -0,0 +1,547 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { + Download, + Clock, + Server, + Wifi, + TrendingDown, + AlertTriangle, + RefreshCw, + AlertCircle, + FileDown, +} from "lucide-react"; +import { Navbar } from "@/components/Navbar"; +import { Footer } from "@/components/Footer"; +import { DashboardSkeleton } from "@/components/DashboardSkeleton"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + BarChart, + Bar, +} from "recharts"; + +interface TestResult { + id: string; + ping_avg: number; + ping_min: number; + ping_max: number; + jitter: number; + packet_loss: number; + isp: string; + country: string; + city: string; + created_at: string; + game_servers: { + location: string; + region: string; + }; +} + +type DateRange = "7" | "30" | "90" | "all"; + +const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [ + { value: "7", label: "Last 7 days" }, + { value: "30", label: "Last 30 days" }, + { value: "90", label: "Last 90 days" }, + { value: "all", label: "All time" }, +]; + +function exportToCSV(results: TestResult[]) { + const headers = [ + "Date", + "Server", + "Region", + "Avg Ping (ms)", + "Min Ping (ms)", + "Max Ping (ms)", + "Jitter (ms)", + "Packet Loss (%)", + "ISP", + "Country", + "City", + ]; + + const rows = results.map((r) => [ + new Date(r.created_at).toISOString(), + r.game_servers?.location ?? "Unknown", + r.game_servers?.region ?? "", + r.ping_avg, + r.ping_min, + r.ping_max, + r.jitter?.toFixed(2) ?? "0", + r.packet_loss, + r.isp ?? "Unknown", + r.country ?? "", + r.city ?? "", + ]); + + const csvContent = [headers, ...rows] + .map((row) => + row + .map((cell) => { + const str = String(cell); + // Wrap in quotes if contains comma, quote, or newline + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }) + .join(",") + ) + .join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `pingdiff-results-${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +export default function DashboardClient() { + const [results, setResults] = useState<TestResult[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [selectedRegion, setSelectedRegion] = useState<string>("all"); + const [dateRange, setDateRange] = useState<DateRange>("30"); + + useEffect(() => { + fetchResults(); + }, []); + + const fetchResults = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/results?limit=100"); + if (!response.ok) { + throw new Error(`Failed to load results (${response.status})`); + } + const data = await response.json(); + setResults(data); + } catch (err) { + console.error("Failed to fetch results:", err); + setError(err instanceof Error ? err.message : "Failed to load results. Please try again."); + } finally { + setLoading(false); + } + }; + + // Apply date range filter + const applyDateFilter = useCallback( + (data: TestResult[]): TestResult[] => { + if (dateRange === "all") return data; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - parseInt(dateRange)); + return data.filter((r) => new Date(r.created_at) >= cutoff); + }, + [dateRange] + ); + + // Apply region filter on top of date filter + const dateFiltered = applyDateFilter(results); + const filteredResults = + selectedRegion === "all" + ? dateFiltered + : dateFiltered.filter((r) => r.game_servers?.region === selectedRegion); + + const avgPing = + filteredResults.length > 0 + ? Math.round( + filteredResults.reduce((sum, r) => sum + r.ping_avg, 0) / + filteredResults.length + ) + : 0; + + const avgPacketLoss = + filteredResults.length > 0 + ? ( + filteredResults.reduce((sum, r) => sum + r.packet_loss, 0) / + filteredResults.length + ).toFixed(1) + : "0"; + + const avgJitter = + filteredResults.length > 0 + ? ( + filteredResults.reduce((sum, r) => sum + (r.jitter || 0), 0) / + filteredResults.length + ).toFixed(1) + : "0"; + + // Get unique regions (from all results, not filtered) + const regions = [ + ...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)), + ]; + + // Prepare chart data + const chartData = filteredResults.slice(0, 20).reverse().map((r, i) => ({ + name: `Test ${i + 1}`, + ping: r.ping_avg, + jitter: r.jitter || 0, + loss: r.packet_loss, + })); + + // Server comparison data + const serverData = filteredResults.reduce((acc, r) => { + const location = r.game_servers?.location || "Unknown"; + if (!acc[location]) { + acc[location] = { pings: [], location }; + } + acc[location].pings.push(r.ping_avg); + return acc; + }, {} as Record<string, { pings: number[]; location: string }>); + + const serverChartData = Object.values(serverData) + .map((s) => ({ + name: s.location.split(" ")[0], + ping: Math.round(s.pings.reduce((a, b) => a + b, 0) / s.pings.length), + })) + .sort((a, b) => a.ping - b.ping); + + const getQualityColor = (ping: number) => { + if (ping < 30) return "text-green-500"; + if (ping < 60) return "text-green-400"; + if (ping < 100) return "text-yellow-500"; + if (ping < 150) return "text-orange-500"; + return "text-red-500"; + }; + + const getQualityLabel = (ping: number) => { + if (ping < 30) return "Excellent"; + if (ping < 60) return "Good"; + if (ping < 100) return "Fair"; + if (ping < 150) return "Poor"; + return "Bad"; + }; + + return ( + <div className="min-h-screen"> + <a href="#main-content" className="skip-to-content focus-ring"> + Skip to main content + </a> + <Navbar /> + + {/* Main Content */} + <main id="main-content" className="max-w-6xl mx-auto px-4 py-8"> + <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8"> + <div> + <h1 className="text-3xl font-bold">Dashboard</h1> + <p className="text-zinc-400">Your connection test results</p> + </div> + + {/* Filters */} + <div className="flex flex-wrap items-center gap-2"> + {/* Date range filter */} + <select + value={dateRange} + onChange={(e) => setDateRange(e.target.value as DateRange)} + className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring" + aria-label="Filter results by date range" + > + {DATE_RANGE_OPTIONS.map((opt) => ( + <option key={opt.value} value={opt.value}> + {opt.label} + </option> + ))} + </select> + + {/* Region filter */} + <select + id="region-filter" + value={selectedRegion} + onChange={(e) => setSelectedRegion(e.target.value)} + className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring" + aria-label="Filter results by region" + > + <option value="all">All Regions</option> + {regions.map((region) => ( + <option key={region} value={region}> + {region} + </option> + ))} + </select> + + {/* Export CSV button */} + {filteredResults.length > 0 && ( + <button + onClick={() => exportToCSV(filteredResults)} + className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring" + aria-label={`Export ${filteredResults.length} results to CSV`} + title="Export filtered results as CSV" + > + <FileDown className="w-4 h-4" /> + Export CSV + </button> + )} + + {/* Refresh */} + <button + onClick={fetchResults} + disabled={loading} + className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring disabled:opacity-50" + aria-label="Refresh results" + > + <RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> + Refresh + </button> + </div> + </div> + + {loading ? ( + <DashboardSkeleton /> + ) : error ? ( + <div className="text-center py-20"> + <AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> + <h2 className="text-xl font-semibold mb-2">Failed to Load Results</h2> + <p className="text-zinc-400 mb-6">{error}</p> + <button + onClick={fetchResults} + className="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium transition" + > + <RefreshCw className="w-5 h-5" /> + Try Again + </button> + </div> + ) : results.length === 0 ? ( + <div className="text-center py-20"> + <Server className="w-16 h-16 text-zinc-600 mx-auto mb-4" /> + <h2 className="text-xl font-semibold mb-2">No Results Yet</h2> + <p className="text-zinc-400 mb-6"> + Download the app and run your first test to see results here. + </p> + <Link + href="/download" + className="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium transition" + > + <Download className="w-5 h-5" /> + Download PingDiff + </Link> + </div> + ) : filteredResults.length === 0 ? ( + <div className="text-center py-20"> + <Clock className="w-16 h-16 text-zinc-600 mx-auto mb-4" /> + <h2 className="text-xl font-semibold mb-2">No Results in This Range</h2> + <p className="text-zinc-400 mb-6"> + No tests found for the selected filters. Try a wider date range or different region. + </p> + <button + onClick={() => { setDateRange("all"); setSelectedRegion("all"); }} + className="inline-flex items-center gap-2 bg-zinc-700 hover:bg-zinc-600 px-6 py-3 rounded-lg font-medium transition" + > + Clear Filters + </button> + </div> + ) : ( + <> + {/* Stats Cards */} + <div + className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" + role="region" + aria-label="Connection statistics summary" + > + <div + className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" + aria-label={`Average ping: ${avgPing} milliseconds — ${getQualityLabel(avgPing)}`} + > + <div className="flex items-center gap-3 mb-2"> + <Wifi className="w-5 h-5 text-blue-500" aria-hidden="true" /> + <span className="text-zinc-400 text-sm">Average Ping</span> + </div> + <div className={`text-3xl font-bold ${getQualityColor(avgPing)}`} aria-hidden="true"> + {avgPing}ms + </div> + <div className="text-sm text-zinc-500"> + {getQualityLabel(avgPing)} + </div> + </div> + + <div + className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" + aria-label={`Average packet loss: ${avgPacketLoss} percent — ${parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"}`} + > + <div className="flex items-center gap-3 mb-2"> + <AlertTriangle className="w-5 h-5 text-orange-500" aria-hidden="true" /> + <span className="text-zinc-400 text-sm">Packet Loss</span> + </div> + <div + className={`text-3xl font-bold ${ + parseFloat(avgPacketLoss) === 0 + ? "text-green-500" + : "text-orange-500" + }`} + aria-hidden="true" + > + {avgPacketLoss}% + </div> + <div className="text-sm text-zinc-500"> + {parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"} + </div> + </div> + + <div + className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" + aria-label={`Average jitter: ${avgJitter} milliseconds`} + > + <div className="flex items-center gap-3 mb-2"> + <TrendingDown className="w-5 h-5 text-purple-500" aria-hidden="true" /> + <span className="text-zinc-400 text-sm">Jitter</span> + </div> + <div className="text-3xl font-bold text-purple-500" aria-hidden="true"> + {avgJitter}ms + </div> + <div className="text-sm text-zinc-500">Variation</div> + </div> + + <div + className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" + aria-label={`Total tests run: ${filteredResults.length}`} + > + <div className="flex items-center gap-3 mb-2"> + <Clock className="w-5 h-5 text-green-500" aria-hidden="true" /> + <span className="text-zinc-400 text-sm">Tests Run</span> + </div> + <div className="text-3xl font-bold text-green-500" aria-hidden="true"> + {filteredResults.length} + </div> + <div className="text-sm text-zinc-500">Total tests</div> + </div> + </div> + + {/* Charts */} + <div className="grid md:grid-cols-2 gap-6 mb-8"> + {/* Ping History Chart */} + <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> + <h3 className="text-lg font-semibold mb-4">Ping History</h3> + <div className="h-64" role="img" aria-label={`Line chart showing ping history across ${chartData.length} recent tests. Latest ping: ${chartData.at(-1)?.ping ?? 0}ms`}> + <ResponsiveContainer width="100%" height="100%"> + <LineChart data={chartData}> + <CartesianGrid strokeDasharray="3 3" stroke="#333" /> + <XAxis dataKey="name" stroke="#666" fontSize={12} /> + <YAxis stroke="#666" fontSize={12} /> + <Tooltip + contentStyle={{ + backgroundColor: "#1a1a1a", + border: "1px solid #333", + borderRadius: "8px", + }} + /> + <Line + type="monotone" + dataKey="ping" + stroke="#3b82f6" + strokeWidth={2} + dot={{ fill: "#3b82f6", r: 4 }} + /> + </LineChart> + </ResponsiveContainer> + </div> + </div> + + {/* Server Comparison Chart */} + <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> + <h3 className="text-lg font-semibold mb-4">Server Comparison</h3> + <div className="h-64" role="img" aria-label={`Bar chart comparing average ping across ${serverChartData.length} servers. Best server: ${serverChartData[0]?.name ?? "N/A"} at ${serverChartData[0]?.ping ?? 0}ms`}> + <ResponsiveContainer width="100%" height="100%"> + <BarChart data={serverChartData}> + <CartesianGrid strokeDasharray="3 3" stroke="#333" /> + <XAxis dataKey="name" stroke="#666" fontSize={12} /> + <YAxis stroke="#666" fontSize={12} /> + <Tooltip + contentStyle={{ + backgroundColor: "#1a1a1a", + border: "1px solid #333", + borderRadius: "8px", + }} + /> + <Bar dataKey="ping" fill="#3b82f6" radius={[4, 4, 0, 0]} /> + </BarChart> + </ResponsiveContainer> + </div> + </div> + </div> + + {/* Recent Results Table */} + <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> + <div className="flex items-center justify-between mb-4"> + <h3 className="text-lg font-semibold">Recent Tests</h3> + <span className="text-sm text-zinc-500"> + Showing {Math.min(filteredResults.length, 10)} of {filteredResults.length} + </span> + </div> + <div className="overflow-x-auto"> + <table + className="w-full" + aria-label={`Recent test results — showing ${Math.min(filteredResults.length, 10)} of ${filteredResults.length}`} + > + <thead> + <tr className="text-left text-zinc-400 text-sm"> + <th scope="col" className="pb-4 font-medium">Server</th> + <th scope="col" className="pb-4 font-medium">Ping</th> + <th scope="col" className="pb-4 font-medium">Jitter</th> + <th scope="col" className="pb-4 font-medium">Loss</th> + <th scope="col" className="pb-4 font-medium">ISP</th> + <th scope="col" className="pb-4 font-medium">Time</th> + </tr> + </thead> + <tbody> + {filteredResults.slice(0, 10).map((result) => ( + <tr key={result.id} className="border-t border-zinc-800"> + <td className="py-4"> + <div className="font-medium"> + {result.game_servers?.location || "Unknown"} + </div> + <div className="text-sm text-zinc-500"> + {result.game_servers?.region} + </div> + </td> + <td + className={`py-4 font-semibold ${getQualityColor(result.ping_avg)}`} + aria-label={`${result.ping_avg} milliseconds — ${getQualityLabel(result.ping_avg)}`} + > + {result.ping_avg}ms + </td> + <td className="py-4 text-zinc-400"> + {result.jitter?.toFixed(1) || "0"}ms + </td> + <td + className={`py-4 ${ + result.packet_loss === 0 + ? "text-green-500" + : "text-orange-500" + }`} + aria-label={`${result.packet_loss} percent packet loss${result.packet_loss === 0 ? " — no loss" : ""}`} + > + {result.packet_loss}% + </td> + <td className="py-4 text-zinc-400"> + {result.isp || "Unknown"} + </td> + <td className="py-4 text-zinc-500 text-sm"> + <time dateTime={result.created_at}> + {new Date(result.created_at).toLocaleDateString()} + </time> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + </> + )} + </main> + + <Footer /> + </div> + ); +} diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 51850c6..1808b47 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,567 +1,15 @@ -"use client"; - -import { useState, useEffect, useCallback, useMemo } from "react"; -import Link from "next/link"; -import { - Download, - Clock, - Server, - Wifi, - TrendingDown, - AlertTriangle, - RefreshCw, - AlertCircle, - FileDown, -} from "lucide-react"; -import { Navbar } from "@/components/Navbar"; -import { Footer } from "@/components/Footer"; -import { DashboardSkeleton } from "@/components/DashboardSkeleton"; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - BarChart, - Bar, -} from "recharts"; - -interface TestResult { - id: string; - ping_avg: number; - ping_min: number; - ping_max: number; - jitter: number; - packet_loss: number; - isp: string; - country: string; - city: string; - created_at: string; - game_servers: { - location: string; - region: string; - }; -} - -type DateRange = "7" | "30" | "90" | "all"; - -const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [ - { value: "7", label: "Last 7 days" }, - { value: "30", label: "Last 30 days" }, - { value: "90", label: "Last 90 days" }, - { value: "all", label: "All time" }, -]; - -function exportToCSV(results: TestResult[]) { - const headers = [ - "Date", - "Server", - "Region", - "Avg Ping (ms)", - "Min Ping (ms)", - "Max Ping (ms)", - "Jitter (ms)", - "Packet Loss (%)", - "ISP", - "Country", - "City", - ]; - - const rows = results.map((r) => [ - new Date(r.created_at).toISOString(), - r.game_servers?.location ?? "Unknown", - r.game_servers?.region ?? "", - r.ping_avg, - r.ping_min, - r.ping_max, - r.jitter?.toFixed(2) ?? "0", - r.packet_loss, - r.isp ?? "Unknown", - r.country ?? "", - r.city ?? "", - ]); - - const csvContent = [headers, ...rows] - .map((row) => - row - .map((cell) => { - const str = String(cell); - // Wrap in quotes if contains comma, quote, or newline - if (str.includes(",") || str.includes('"') || str.includes("\n")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - }) - .join(",") - ) - .join("\n"); - - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `pingdiff-results-${new Date().toISOString().slice(0, 10)}.csv`; - link.click(); - URL.revokeObjectURL(url); -} +import type { Metadata } from "next"; +import DashboardClient from "./DashboardClient"; + +export const metadata: Metadata = { + title: "Dashboard", + description: "View your ping test results, packet loss trends, and server comparisons. Analyze your connection history across all game servers.", + openGraph: { + title: "PingDiff Dashboard — Your Connection Test Results", + description: "View your ping test results, packet loss trends, and server comparisons.", + }, +}; export default function DashboardPage() { - const [results, setResults] = useState<TestResult[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const [selectedRegion, setSelectedRegion] = useState<string>("all"); - const [dateRange, setDateRange] = useState<DateRange>("30"); - - useEffect(() => { - fetchResults(); - }, []); - - const fetchResults = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch("/api/results?limit=100"); - if (!response.ok) { - throw new Error(`Failed to load results (${response.status})`); - } - const data = await response.json(); - setResults(data); - } catch (err) { - console.error("Failed to fetch results:", err); - setError(err instanceof Error ? err.message : "Failed to load results. Please try again."); - } finally { - setLoading(false); - } - }; - - // Apply date range filter - const applyDateFilter = useCallback( - (data: TestResult[]): TestResult[] => { - if (dateRange === "all") return data; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - parseInt(dateRange)); - return data.filter((r) => new Date(r.created_at) >= cutoff); - }, - [dateRange] - ); - - // Apply date + region filters — memoized so they only recompute when deps change - const filteredResults = useMemo(() => { - const dateFiltered = applyDateFilter(results); - return selectedRegion === "all" - ? dateFiltered - : dateFiltered.filter((r) => r.game_servers?.region === selectedRegion); - }, [results, applyDateFilter, selectedRegion]); - - // Aggregate stats — memoized on filteredResults - const avgPing = useMemo( - () => - filteredResults.length > 0 - ? Math.round( - filteredResults.reduce((sum, r) => sum + r.ping_avg, 0) / - filteredResults.length - ) - : 0, - [filteredResults] - ); - - const avgPacketLoss = useMemo( - () => - filteredResults.length > 0 - ? ( - filteredResults.reduce((sum, r) => sum + r.packet_loss, 0) / - filteredResults.length - ).toFixed(1) - : "0", - [filteredResults] - ); - - const avgJitter = useMemo( - () => - filteredResults.length > 0 - ? ( - filteredResults.reduce((sum, r) => sum + (r.jitter || 0), 0) / - filteredResults.length - ).toFixed(1) - : "0", - [filteredResults] - ); - - // Unique regions derived from all results (unaffected by filters) - const regions = useMemo( - () => [ - ...new Set(results.map((r) => r.game_servers?.region).filter(Boolean)), - ], - [results] - ); - - // Ping history chart data - const chartData = useMemo( - () => - filteredResults.slice(0, 20).reverse().map((r, i) => ({ - name: `Test ${i + 1}`, - ping: r.ping_avg, - jitter: r.jitter || 0, - loss: r.packet_loss, - })), - [filteredResults] - ); - - // Server comparison chart data — group by location, sort by avg ping - const serverChartData = useMemo(() => { - const serverData = filteredResults.reduce((acc, r) => { - const location = r.game_servers?.location || "Unknown"; - if (!acc[location]) { - acc[location] = { pings: [], location }; - } - acc[location].pings.push(r.ping_avg); - return acc; - }, {} as Record<string, { pings: number[]; location: string }>); - - return Object.values(serverData) - .map((s) => ({ - name: s.location.split(" ")[0], - ping: Math.round(s.pings.reduce((a, b) => a + b, 0) / s.pings.length), - })) - .sort((a, b) => a.ping - b.ping); - }, [filteredResults]); - - const getQualityColor = (ping: number) => { - if (ping < 30) return "text-green-500"; - if (ping < 60) return "text-green-400"; - if (ping < 100) return "text-yellow-500"; - if (ping < 150) return "text-orange-500"; - return "text-red-500"; - }; - - const getQualityLabel = (ping: number) => { - if (ping < 30) return "Excellent"; - if (ping < 60) return "Good"; - if (ping < 100) return "Fair"; - if (ping < 150) return "Poor"; - return "Bad"; - }; - - return ( - <div className="min-h-screen"> - <a href="#main-content" className="skip-to-content focus-ring"> - Skip to main content - </a> - <Navbar /> - - {/* Main Content */} - <main id="main-content" className="max-w-6xl mx-auto px-4 py-8"> - <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8"> - <div> - <h1 className="text-3xl font-bold">Dashboard</h1> - <p className="text-zinc-400">Your connection test results</p> - </div> - - {/* Filters */} - <div className="flex flex-wrap items-center gap-2"> - {/* Date range filter */} - <select - value={dateRange} - onChange={(e) => setDateRange(e.target.value as DateRange)} - className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring" - aria-label="Filter results by date range" - > - {DATE_RANGE_OPTIONS.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} - </option> - ))} - </select> - - {/* Region filter */} - <select - id="region-filter" - value={selectedRegion} - onChange={(e) => setSelectedRegion(e.target.value)} - className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm focus-ring" - aria-label="Filter results by region" - > - <option value="all">All Regions</option> - {regions.map((region) => ( - <option key={region} value={region}> - {region} - </option> - ))} - </select> - - {/* Export CSV button */} - {filteredResults.length > 0 && ( - <button - onClick={() => exportToCSV(filteredResults)} - className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring" - aria-label={`Export ${filteredResults.length} results to CSV`} - title="Export filtered results as CSV" - > - <FileDown className="w-4 h-4" /> - Export CSV - </button> - )} - - {/* Refresh */} - <button - onClick={fetchResults} - disabled={loading} - className="inline-flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-2 rounded-lg text-sm font-medium transition focus-ring disabled:opacity-50" - aria-label="Refresh results" - > - <RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> - Refresh - </button> - </div> - </div> - - {loading ? ( - <DashboardSkeleton /> - ) : error ? ( - <div className="text-center py-20"> - <AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> - <h2 className="text-xl font-semibold mb-2">Failed to Load Results</h2> - <p className="text-zinc-400 mb-6">{error}</p> - <button - onClick={fetchResults} - className="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium transition" - > - <RefreshCw className="w-5 h-5" /> - Try Again - </button> - </div> - ) : results.length === 0 ? ( - <div className="text-center py-20"> - <Server className="w-16 h-16 text-zinc-600 mx-auto mb-4" /> - <h2 className="text-xl font-semibold mb-2">No Results Yet</h2> - <p className="text-zinc-400 mb-6"> - Download the app and run your first test to see results here. - </p> - <Link - href="/download" - className="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium transition" - > - <Download className="w-5 h-5" /> - Download PingDiff - </Link> - </div> - ) : filteredResults.length === 0 ? ( - <div className="text-center py-20"> - <Clock className="w-16 h-16 text-zinc-600 mx-auto mb-4" /> - <h2 className="text-xl font-semibold mb-2">No Results in This Range</h2> - <p className="text-zinc-400 mb-6"> - No tests found for the selected filters. Try a wider date range or different region. - </p> - <button - onClick={() => { setDateRange("all"); setSelectedRegion("all"); }} - className="inline-flex items-center gap-2 bg-zinc-700 hover:bg-zinc-600 px-6 py-3 rounded-lg font-medium transition" - > - Clear Filters - </button> - </div> - ) : ( - <> - {/* Stats Cards */} - <div - className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" - role="region" - aria-label="Connection statistics summary" - > - <div - className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" - aria-label={`Average ping: ${avgPing} milliseconds — ${getQualityLabel(avgPing)}`} - > - <div className="flex items-center gap-3 mb-2"> - <Wifi className="w-5 h-5 text-blue-500" aria-hidden="true" /> - <span className="text-zinc-400 text-sm">Average Ping</span> - </div> - <div className={`text-3xl font-bold ${getQualityColor(avgPing)}`} aria-hidden="true"> - {avgPing}ms - </div> - <div className="text-sm text-zinc-500"> - {getQualityLabel(avgPing)} - </div> - </div> - - <div - className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" - aria-label={`Average packet loss: ${avgPacketLoss} percent — ${parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"}`} - > - <div className="flex items-center gap-3 mb-2"> - <AlertTriangle className="w-5 h-5 text-orange-500" aria-hidden="true" /> - <span className="text-zinc-400 text-sm">Packet Loss</span> - </div> - <div - className={`text-3xl font-bold ${ - parseFloat(avgPacketLoss) === 0 - ? "text-green-500" - : "text-orange-500" - }`} - aria-hidden="true" - > - {avgPacketLoss}% - </div> - <div className="text-sm text-zinc-500"> - {parseFloat(avgPacketLoss) === 0 ? "No loss" : "Some loss"} - </div> - </div> - - <div - className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" - aria-label={`Average jitter: ${avgJitter} milliseconds`} - > - <div className="flex items-center gap-3 mb-2"> - <TrendingDown className="w-5 h-5 text-purple-500" aria-hidden="true" /> - <span className="text-zinc-400 text-sm">Jitter</span> - </div> - <div className="text-3xl font-bold text-purple-500" aria-hidden="true"> - {avgJitter}ms - </div> - <div className="text-sm text-zinc-500">Variation</div> - </div> - - <div - className="bg-zinc-900 border border-zinc-800 rounded-xl p-6" - aria-label={`Total tests run: ${filteredResults.length}`} - > - <div className="flex items-center gap-3 mb-2"> - <Clock className="w-5 h-5 text-green-500" aria-hidden="true" /> - <span className="text-zinc-400 text-sm">Tests Run</span> - </div> - <div className="text-3xl font-bold text-green-500" aria-hidden="true"> - {filteredResults.length} - </div> - <div className="text-sm text-zinc-500">Total tests</div> - </div> - </div> - - {/* Charts */} - <div className="grid md:grid-cols-2 gap-6 mb-8"> - {/* Ping History Chart */} - <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> - <h3 className="text-lg font-semibold mb-4">Ping History</h3> - <div className="h-64" role="img" aria-label={`Line chart showing ping history across ${chartData.length} recent tests. Latest ping: ${chartData.at(-1)?.ping ?? 0}ms`}> - <ResponsiveContainer width="100%" height="100%"> - <LineChart data={chartData}> - <CartesianGrid strokeDasharray="3 3" stroke="#333" /> - <XAxis dataKey="name" stroke="#666" fontSize={12} /> - <YAxis stroke="#666" fontSize={12} /> - <Tooltip - contentStyle={{ - backgroundColor: "#1a1a1a", - border: "1px solid #333", - borderRadius: "8px", - }} - /> - <Line - type="monotone" - dataKey="ping" - stroke="#3b82f6" - strokeWidth={2} - dot={{ fill: "#3b82f6", r: 4 }} - /> - </LineChart> - </ResponsiveContainer> - </div> - </div> - - {/* Server Comparison Chart */} - <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> - <h3 className="text-lg font-semibold mb-4">Server Comparison</h3> - <div className="h-64" role="img" aria-label={`Bar chart comparing average ping across ${serverChartData.length} servers. Best server: ${serverChartData[0]?.name ?? "N/A"} at ${serverChartData[0]?.ping ?? 0}ms`}> - <ResponsiveContainer width="100%" height="100%"> - <BarChart data={serverChartData}> - <CartesianGrid strokeDasharray="3 3" stroke="#333" /> - <XAxis dataKey="name" stroke="#666" fontSize={12} /> - <YAxis stroke="#666" fontSize={12} /> - <Tooltip - contentStyle={{ - backgroundColor: "#1a1a1a", - border: "1px solid #333", - borderRadius: "8px", - }} - /> - <Bar dataKey="ping" fill="#3b82f6" radius={[4, 4, 0, 0]} /> - </BarChart> - </ResponsiveContainer> - </div> - </div> - </div> - - {/* Recent Results Table */} - <div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6"> - <div className="flex items-center justify-between mb-4"> - <h3 className="text-lg font-semibold">Recent Tests</h3> - <span className="text-sm text-zinc-500"> - Showing {Math.min(filteredResults.length, 10)} of {filteredResults.length} - </span> - </div> - <div className="overflow-x-auto"> - <table - className="w-full" - aria-label={`Recent test results — showing ${Math.min(filteredResults.length, 10)} of ${filteredResults.length}`} - > - <thead> - <tr className="text-left text-zinc-400 text-sm"> - <th scope="col" className="pb-4 font-medium">Server</th> - <th scope="col" className="pb-4 font-medium">Ping</th> - <th scope="col" className="pb-4 font-medium">Jitter</th> - <th scope="col" className="pb-4 font-medium">Loss</th> - <th scope="col" className="pb-4 font-medium">ISP</th> - <th scope="col" className="pb-4 font-medium">Time</th> - </tr> - </thead> - <tbody> - {filteredResults.slice(0, 10).map((result) => ( - <tr key={result.id} className="border-t border-zinc-800"> - <td className="py-4"> - <div className="font-medium"> - {result.game_servers?.location || "Unknown"} - </div> - <div className="text-sm text-zinc-500"> - {result.game_servers?.region} - </div> - </td> - <td - className={`py-4 font-semibold ${getQualityColor(result.ping_avg)}`} - aria-label={`${result.ping_avg} milliseconds — ${getQualityLabel(result.ping_avg)}`} - > - {result.ping_avg}ms - </td> - <td className="py-4 text-zinc-400"> - {result.jitter?.toFixed(1) || "0"}ms - </td> - <td - className={`py-4 ${ - result.packet_loss === 0 - ? "text-green-500" - : "text-orange-500" - }`} - aria-label={`${result.packet_loss} percent packet loss${result.packet_loss === 0 ? " — no loss" : ""}`} - > - {result.packet_loss}% - </td> - <td className="py-4 text-zinc-400"> - {result.isp || "Unknown"} - </td> - <td className="py-4 text-zinc-500 text-sm"> - <time dateTime={result.created_at}> - {new Date(result.created_at).toLocaleDateString()} - </time> - </td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - </> - )} - </main> - - <Footer /> - </div> - ); + return <DashboardClient />; } diff --git a/web/src/app/download/DownloadClient.tsx b/web/src/app/download/DownloadClient.tsx new file mode 100644 index 0000000..5cf3a1e --- /dev/null +++ b/web/src/app/download/DownloadClient.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Activity, Download, Shield, Settings, CheckCircle, Loader2, FolderOpen, AlertCircle, Github } from "lucide-react"; +import { Navbar } from "@/components/Navbar"; +import { Footer } from "@/components/Footer"; + +interface ReleaseInfo { + version: string; + downloadUrl: string; + size: string; + date: string; + isInstaller: boolean; +} + +export default function DownloadClient() { + const [release, setRelease] = useState<ReleaseInfo | null>(null); + const [loading, setLoading] = useState(true); + const [usingFallback, setUsingFallback] = useState(false); + + useEffect(() => { + // Fetch latest release from GitHub API + fetch("https://api.github.com/repos/bokiko/pingdiff/releases/latest") + .then(res => { + if (!res.ok) throw new Error("API error"); + return res.json(); + }) + .then(data => { + const asset = data.assets?.find((a: { name: string }) => a.name.endsWith('.exe')); + if (asset) { + const isInstaller = asset.name.includes('Setup'); + const sizeInMB = (asset.size / (1024 * 1024)).toFixed(1); + setRelease({ + version: data.tag_name || "v1.17.1", + downloadUrl: asset.browser_download_url, + size: `${sizeInMB} MB`, + date: new Date(data.published_at).toLocaleDateString(), + isInstaller + }); + } else { + throw new Error("No exe found"); + } + setLoading(false); + }) + .catch(() => { + // Fallback - still provide download link + setRelease({ + version: "v1.17.1", + downloadUrl: "https://github.com/bokiko/pingdiff/releases/latest", + size: "~11 MB", + date: "", + isInstaller: true + }); + setUsingFallback(true); + setLoading(false); + }); + }, []); + + const getFileName = () => { + if (!release) return "PingDiff"; + const version = release.version.replace('v', ''); + return release.isInstaller ? `PingDiff-Setup-${version}.exe` : `PingDiff-${release.version}.exe`; + }; + + return ( + <div className="min-h-screen"> + <a href="#main-content" className="skip-to-content focus-ring"> + Skip to main content + </a> + <Navbar /> + + {/* Main Content */} + <main id="main-content" className="max-w-4xl mx-auto px-4 py-16"> + <div className="text-center mb-12"> + <h1 className="text-4xl font-bold mb-4">Download PingDiff</h1> + <p className="text-zinc-400 text-lg"> + Get the desktop app to test your connection to game servers + </p> + </div> + + {/* Fallback Notice */} + {usingFallback && ( + <div className="bg-yellow-900/20 border border-yellow-700/50 rounded-xl p-4 mb-6 flex items-center gap-3"> + <AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0" /> + <p className="text-yellow-200 text-sm"> + Couldn't fetch latest version info. Showing fallback download link. + Check <a href="https://github.com/bokiko/pingdiff/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-white">GitHub Releases</a> for the latest version. + </p> + </div> + )} + + {/* Download Card */} + <div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 mb-8"> + <div className="flex flex-col md:flex-row items-center gap-8"> + <div className="w-24 h-24 bg-blue-600/20 rounded-2xl flex items-center justify-center"> + <Activity className="w-12 h-12 text-blue-500" /> + </div> + + <div className="flex-1 text-center md:text-left"> + <h2 className="text-2xl font-bold mb-2">PingDiff for Windows</h2> + {loading ? ( + <div className="flex items-center gap-2 text-zinc-400 mb-4"> + <Loader2 className="w-4 h-4 animate-spin" /> + <span>Loading version info...</span> + </div> + ) : ( + <p className="text-zinc-400 mb-4"> + {release?.version} • {release?.size} • Windows 10/11 + {release?.date && <span className="text-zinc-500"> • Released {release.date}</span>} + </p> + )} + + <a + href={release?.downloadUrl || "#"} + className={`inline-flex items-center gap-2 btn-primary px-8 py-4 rounded-xl font-semibold text-lg focus-ring ${loading ? 'opacity-50 pointer-events-none' : ''}`} + > + <Download className="w-5 h-5" /> + {loading ? "Loading..." : `Download ${getFileName()}`} + </a> + </div> + </div> + </div> + + {/* Features */} + <div className="grid md:grid-cols-3 gap-6 mb-12"> + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> + <div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4"> + <Shield className="w-6 h-6 text-green-500" /> + </div> + <h3 className="font-semibold mb-2">Safe & Open Source</h3> + <p className="text-zinc-400 text-sm leading-relaxed"> + 100% open source. No malware, no tracking, no ads. Check the code yourself on GitHub or GitLab. + </p> + </div> + + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> + <div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center mb-4"> + <Settings className="w-6 h-6 text-blue-500" /> + </div> + <h3 className="font-semibold mb-2">Your Privacy, Your Choice</h3> + <p className="text-zinc-400 text-sm leading-relaxed"> + Toggle result sharing on or off. Your settings are saved locally and persist across updates. + </p> + </div> + + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> + <div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center mb-4"> + <FolderOpen className="w-6 h-6 text-purple-500" /> + </div> + <h3 className="font-semibold mb-2">Clean Install & Updates</h3> + <p className="text-zinc-400 text-sm leading-relaxed"> + Proper Windows installer. Updates automatically clean up old versions while preserving your data. + </p> + </div> + </div> + + {/* System Requirements */} + <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 mb-12"> + <h3 className="text-xl font-semibold mb-4">System Requirements</h3> + <div className="grid md:grid-cols-2 gap-4"> + <div className="flex items-center gap-3"> + <CheckCircle className="w-5 h-5 text-green-500" /> + <span className="text-zinc-300">Windows 10 or Windows 11</span> + </div> + <div className="flex items-center gap-3"> + <CheckCircle className="w-5 h-5 text-green-500" /> + <span className="text-zinc-300">Internet connection</span> + </div> + <div className="flex items-center gap-3"> + <CheckCircle className="w-5 h-5 text-green-500" /> + <span className="text-zinc-300">50MB free disk space</span> + </div> + <div className="flex items-center gap-3"> + <CheckCircle className="w-5 h-5 text-green-500" /> + <span className="text-zinc-300">Admin rights for installation</span> + </div> + </div> + </div> + + {/* Data Storage Info */} + <div className="bg-blue-950/30 border border-blue-800/50 rounded-xl p-6 mb-12"> + <h3 className="text-lg font-semibold mb-3 flex items-center gap-2"> + <FolderOpen className="w-5 h-5 text-blue-400" /> + Where is my data stored? + </h3> + <div className="text-zinc-300 text-sm space-y-2"> + <p><span className="text-zinc-400">Program files:</span> <code className="bg-zinc-800 px-2 py-0.5 rounded">C:\Program Files\PingDiff</code></p> + <p><span className="text-zinc-400">Settings & logs:</span> <code className="bg-zinc-800 px-2 py-0.5 rounded">%APPDATA%\PingDiff</code></p> + <p className="text-zinc-500 mt-3">Your settings and logs are preserved when you update to a new version.</p> + </div> + </div> + + {/* Other Platforms */} + <div className="text-center"> + <h3 className="text-xl font-semibold mb-4">Other Platforms</h3> + <p className="text-zinc-400 mb-6"> + Mac and Linux versions coming soon. Want to be notified? + </p> + <div className="flex justify-center gap-4"> + <span className="bg-zinc-800 text-zinc-500 px-4 py-2 rounded-lg"> + macOS - Coming Soon + </span> + <span className="bg-zinc-800 text-zinc-500 px-4 py-2 rounded-lg"> + Linux - Coming Soon + </span> + </div> + </div> + + {/* Instructions */} + <div className="mt-16"> + <h3 className="text-xl font-semibold mb-6 text-center">How to Use</h3> + <div className="grid md:grid-cols-4 gap-4"> + <div className="text-center"> + <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> + 1 + </div> + <p className="text-sm text-zinc-400">Download & run the installer</p> + </div> + <div className="text-center"> + <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> + 2 + </div> + <p className="text-sm text-zinc-400">Launch PingDiff from Start Menu</p> + </div> + <div className="text-center"> + <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> + 3 + </div> + <p className="text-sm text-zinc-400">Select your region</p> + </div> + <div className="text-center"> + <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> + 4 + </div> + <p className="text-sm text-zinc-400">Click Test and see results</p> + </div> + </div> + </div> + + {/* All Releases Links */} + <div className="mt-12 text-center flex justify-center gap-6"> + <a + href="https://github.com/bokiko/pingdiff/releases" + target="_blank" + rel="noopener noreferrer" + className="text-zinc-400 hover:text-white transition flex items-center gap-2" + > + <Github className="w-4 h-4" /> + GitHub Releases + </a> + <a + href="https://gitlab.com/bokiko/pingdiff/-/releases" + target="_blank" + rel="noopener noreferrer" + className="text-orange-400 hover:text-orange-300 transition flex items-center gap-2" + > + <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> + <path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 00-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 00-.867 0L1.386 9.45.044 13.587a.924.924 0 00.331 1.023L12 23.054l11.625-8.443a.92.92 0 00.33-1.024"/> + </svg> + GitLab Releases + </a> + </div> + </main> + + <Footer /> + </div> + ); +} diff --git a/web/src/app/download/page.tsx b/web/src/app/download/page.tsx index 8c3d4b7..cb03c09 100644 --- a/web/src/app/download/page.tsx +++ b/web/src/app/download/page.tsx @@ -1,269 +1,15 @@ -"use client"; - -import { useState } from "react"; -import { Activity, Download, Shield, Settings, CheckCircle, Loader2, FolderOpen, AlertCircle, Github } from "lucide-react"; -import { useEffect } from "react"; -import { Navbar } from "@/components/Navbar"; -import { Footer } from "@/components/Footer"; - -interface ReleaseInfo { - version: string; - downloadUrl: string; - size: string; - date: string; - isInstaller: boolean; -} +import type { Metadata } from "next"; +import DownloadClient from "./DownloadClient"; + +export const metadata: Metadata = { + title: "Download", + description: "Download PingDiff for Windows — free, open source desktop app to test your ping and packet loss to game servers before you queue.", + openGraph: { + title: "Download PingDiff for Windows", + description: "Free, open source desktop app to test your ping and packet loss to game servers before you queue.", + }, +}; export default function DownloadPage() { - const [release, setRelease] = useState<ReleaseInfo | null>(null); - const [loading, setLoading] = useState(true); - const [usingFallback, setUsingFallback] = useState(false); - - useEffect(() => { - // Fetch latest release from GitHub API - fetch("https://api.github.com/repos/bokiko/pingdiff/releases/latest") - .then(res => { - if (!res.ok) throw new Error("API error"); - return res.json(); - }) - .then(data => { - const asset = data.assets?.find((a: { name: string }) => a.name.endsWith('.exe')); - if (asset) { - const isInstaller = asset.name.includes('Setup'); - const sizeInMB = (asset.size / (1024 * 1024)).toFixed(1); - setRelease({ - version: data.tag_name || "v1.17.1", - downloadUrl: asset.browser_download_url, - size: `${sizeInMB} MB`, - date: new Date(data.published_at).toLocaleDateString(), - isInstaller - }); - } else { - throw new Error("No exe found"); - } - setLoading(false); - }) - .catch(() => { - // Fallback - still provide download link - setRelease({ - version: "v1.17.1", - downloadUrl: "https://github.com/bokiko/pingdiff/releases/latest", - size: "~11 MB", - date: "", - isInstaller: true - }); - setUsingFallback(true); - setLoading(false); - }); - }, []); - - const getFileName = () => { - if (!release) return "PingDiff"; - const version = release.version.replace('v', ''); - return release.isInstaller ? `PingDiff-Setup-${version}.exe` : `PingDiff-${release.version}.exe`; - }; - - return ( - <div className="min-h-screen"> - <a href="#main-content" className="skip-to-content focus-ring"> - Skip to main content - </a> - <Navbar /> - - {/* Main Content */} - <main id="main-content" className="max-w-4xl mx-auto px-4 py-16"> - <div className="text-center mb-12"> - <h1 className="text-4xl font-bold mb-4">Download PingDiff</h1> - <p className="text-zinc-400 text-lg"> - Get the desktop app to test your connection to game servers - </p> - </div> - - {/* Fallback Notice */} - {usingFallback && ( - <div className="bg-yellow-900/20 border border-yellow-700/50 rounded-xl p-4 mb-6 flex items-center gap-3"> - <AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0" /> - <p className="text-yellow-200 text-sm"> - Couldn't fetch latest version info. Showing fallback download link. - Check <a href="https://github.com/bokiko/pingdiff/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-white">GitHub Releases</a> for the latest version. - </p> - </div> - )} - - {/* Download Card */} - <div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 mb-8"> - <div className="flex flex-col md:flex-row items-center gap-8"> - <div className="w-24 h-24 bg-blue-600/20 rounded-2xl flex items-center justify-center"> - <Activity className="w-12 h-12 text-blue-500" /> - </div> - - <div className="flex-1 text-center md:text-left"> - <h2 className="text-2xl font-bold mb-2">PingDiff for Windows</h2> - {loading ? ( - <div className="flex items-center gap-2 text-zinc-400 mb-4"> - <Loader2 className="w-4 h-4 animate-spin" /> - <span>Loading version info...</span> - </div> - ) : ( - <p className="text-zinc-400 mb-4"> - {release?.version} • {release?.size} • Windows 10/11 - {release?.date && <span className="text-zinc-500"> • Released {release.date}</span>} - </p> - )} - - <a - href={release?.downloadUrl || "#"} - className={`inline-flex items-center gap-2 btn-primary px-8 py-4 rounded-xl font-semibold text-lg focus-ring ${loading ? 'opacity-50 pointer-events-none' : ''}`} - > - <Download className="w-5 h-5" /> - {loading ? "Loading..." : `Download ${getFileName()}`} - </a> - </div> - </div> - </div> - - {/* Features */} - <div className="grid md:grid-cols-3 gap-6 mb-12"> - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> - <div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4"> - <Shield className="w-6 h-6 text-green-500" /> - </div> - <h3 className="font-semibold mb-2">Safe & Open Source</h3> - <p className="text-zinc-400 text-sm leading-relaxed"> - 100% open source. No malware, no tracking, no ads. Check the code yourself on GitHub or GitLab. - </p> - </div> - - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> - <div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center mb-4"> - <Settings className="w-6 h-6 text-blue-500" /> - </div> - <h3 className="font-semibold mb-2">Your Privacy, Your Choice</h3> - <p className="text-zinc-400 text-sm leading-relaxed"> - Toggle result sharing on or off. Your settings are saved locally and persist across updates. - </p> - </div> - - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 card-hover"> - <div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center mb-4"> - <FolderOpen className="w-6 h-6 text-purple-500" /> - </div> - <h3 className="font-semibold mb-2">Clean Install & Updates</h3> - <p className="text-zinc-400 text-sm leading-relaxed"> - Proper Windows installer. Updates automatically clean up old versions while preserving your data. - </p> - </div> - </div> - - {/* System Requirements */} - <div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 mb-12"> - <h3 className="text-xl font-semibold mb-4">System Requirements</h3> - <div className="grid md:grid-cols-2 gap-4"> - <div className="flex items-center gap-3"> - <CheckCircle className="w-5 h-5 text-green-500" /> - <span className="text-zinc-300">Windows 10 or Windows 11</span> - </div> - <div className="flex items-center gap-3"> - <CheckCircle className="w-5 h-5 text-green-500" /> - <span className="text-zinc-300">Internet connection</span> - </div> - <div className="flex items-center gap-3"> - <CheckCircle className="w-5 h-5 text-green-500" /> - <span className="text-zinc-300">50MB free disk space</span> - </div> - <div className="flex items-center gap-3"> - <CheckCircle className="w-5 h-5 text-green-500" /> - <span className="text-zinc-300">Admin rights for installation</span> - </div> - </div> - </div> - - {/* Data Storage Info */} - <div className="bg-blue-950/30 border border-blue-800/50 rounded-xl p-6 mb-12"> - <h3 className="text-lg font-semibold mb-3 flex items-center gap-2"> - <FolderOpen className="w-5 h-5 text-blue-400" /> - Where is my data stored? - </h3> - <div className="text-zinc-300 text-sm space-y-2"> - <p><span className="text-zinc-400">Program files:</span> <code className="bg-zinc-800 px-2 py-0.5 rounded">C:\Program Files\PingDiff</code></p> - <p><span className="text-zinc-400">Settings & logs:</span> <code className="bg-zinc-800 px-2 py-0.5 rounded">%APPDATA%\PingDiff</code></p> - <p className="text-zinc-500 mt-3">Your settings and logs are preserved when you update to a new version.</p> - </div> - </div> - - {/* Other Platforms */} - <div className="text-center"> - <h3 className="text-xl font-semibold mb-4">Other Platforms</h3> - <p className="text-zinc-400 mb-6"> - Mac and Linux versions coming soon. Want to be notified? - </p> - <div className="flex justify-center gap-4"> - <span className="bg-zinc-800 text-zinc-500 px-4 py-2 rounded-lg"> - macOS - Coming Soon - </span> - <span className="bg-zinc-800 text-zinc-500 px-4 py-2 rounded-lg"> - Linux - Coming Soon - </span> - </div> - </div> - - {/* Instructions */} - <div className="mt-16"> - <h3 className="text-xl font-semibold mb-6 text-center">How to Use</h3> - <div className="grid md:grid-cols-4 gap-4"> - <div className="text-center"> - <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> - 1 - </div> - <p className="text-sm text-zinc-400">Download & run the installer</p> - </div> - <div className="text-center"> - <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> - 2 - </div> - <p className="text-sm text-zinc-400">Launch PingDiff from Start Menu</p> - </div> - <div className="text-center"> - <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> - 3 - </div> - <p className="text-sm text-zinc-400">Select your region</p> - </div> - <div className="text-center"> - <div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"> - 4 - </div> - <p className="text-sm text-zinc-400">Click Test and see results</p> - </div> - </div> - </div> - - {/* All Releases Links */} - <div className="mt-12 text-center flex justify-center gap-6"> - <a - href="https://github.com/bokiko/pingdiff/releases" - target="_blank" - rel="noopener noreferrer" - className="text-zinc-400 hover:text-white transition flex items-center gap-2" - > - <Github className="w-4 h-4" /> - GitHub Releases - </a> - <a - href="https://gitlab.com/bokiko/pingdiff/-/releases" - target="_blank" - rel="noopener noreferrer" - className="text-orange-400 hover:text-orange-300 transition flex items-center gap-2" - > - <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> - <path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 00-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 00-.867 0L1.386 9.45.044 13.587a.924.924 0 00.331 1.023L12 23.054l11.625-8.443a.92.92 0 00.33-1.024"/> - </svg> - GitLab Releases - </a> - </div> - </main> - - <Footer /> - </div> - ); + return <DownloadClient />; } From 4824f688fec2345d1e33d43a6b763d8ab8aa390c Mon Sep 17 00:00:00 2001 From: bokiko <bokiko@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:14:13 +0000 Subject: [PATCH 3/3] docs: update IMPROVEMENTS.md for 2026-03-20 bug fix --- IMPROVEMENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 4319ee4..64b0225 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,5 +1,20 @@ # PingDiff Improvement Log +## 2026-03-20 — Bug Fix: Per-page metadata for dashboard, download, and community + +All three main pages used `"use client"` at the top level, which blocks Next.js from reading the +`metadata` export. Every page fell back to the generic homepage title and description — so sharing +any page on Discord, Twitter, or iMessage showed "PingDiff - Test Your Game Server Connection" +regardless of which page it was. The `template: "%s | PingDiff"` in `layout.tsx` was dead code. + +Fixed by splitting each page into a thin server wrapper (`page.tsx`, exports metadata) and a +client component (`*Client.tsx`, holds all interactive state). No logic changed — pure structural +refactor following the standard App Router pattern. Each page now has a distinct title, +description, and og:title/og:description for accurate social previews. + +**Files changed:** `web/src/app/dashboard/page.tsx`, `web/src/app/dashboard/DashboardClient.tsx` (new), `web/src/app/download/page.tsx`, `web/src/app/download/DownloadClient.tsx` (new), `web/src/app/community/page.tsx`, `web/src/app/community/CommunityClient.tsx` (new) +**Lines:** +934 / -890 + ## 2026-03-20 — Accessibility: ARIA labels, table semantics, and skip navigation Comprehensive a11y pass across the dashboard, navbar, and secondary pages. The site had no named navigation landmark, no skip links on 3 of 4 pages, tables without column scope attributes, charts completely invisible to assistive technology, and stat cards that conveyed quality purely through color (WCAG 1.4.1 violation). All fixed without new dependencies.