By: Mayne
Visualize Download Data for Specified GitHub Repository Releases
"use client"
import { useEffect, useState } from "react"
import {
AlertCircle,
BarChart as BarChartIcon,
Download,
GitBranch,
Globe,
Key,
PieChart as PieChartIcon,
RefreshCw,
Star,
Tag,
X,
} from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
const GITHUB_API_BASE = "https://api.github.com"
const CACHE_EXPIRY = 60 * 60 * 1000 // Cache for 1 hour
const API_KEY_STORAGE_KEY = "github_api_key"
const REPO_STORAGE_KEY = "github_repos"
interface Asset {
name: string
download_count: number
}
interface Release {
tag_name: string
published_at: string
assets: Asset[]
prerelease?: boolean
}
interface RepoInfo {
stargazers_count: number
forks_count: number
}
interface ChartDataPoint {
name: string
downloads: number
}
interface ReleaseData {
totalDownloads: number
latestRelease: {
tag: string
date: string
downloads: number
}
weeklyDownloads: ChartDataPoint[]
stars: number
forks: number
releases: Release[]
}
async function fetchGitHubReleaseData(
repo: string,
apiKey: string,
includePrerelease: boolean = true
): Promise<ReleaseData> {
"use server"
if (!apiKey) {
throw new Error("GitHub token is not set, please set it in the settings")
}
const headers = {
Authorization: `token ${apiKey}`,
Accept: "application/vnd.github.v3+json",
}
// 预发布版本标识符
const prereleasePatterns = [
"alpha",
"beta",
"canary",
"rc",
"dev",
"preview",
"snapshot",
]
const isPrerelease = (tagName: string): boolean => {
const lowerTag = tagName.toLowerCase()
return prereleasePatterns.some((pattern) => lowerTag.includes(pattern))
}
try {
const releasesResponse = await fetch(
`${GITHUB_API_BASE}/repos/${repo}/releases`,
{ headers }
)
if (!releasesResponse.ok) throw new Error("Failed to fetch releases")
const allReleases: Release[] = await releasesResponse.json()
// 根据参数过滤发布版本
const releases = includePrerelease
? allReleases
: allReleases.filter((release) => !isPrerelease(release.tag_name))
const repoInfoResponse = await fetch(`${GITHUB_API_BASE}/repos/${repo}`, {
headers,
})
if (!repoInfoResponse.ok) throw new Error("Failed to fetch repository info")
const repoInfo: RepoInfo = await repoInfoResponse.json()
let totalDownloads = 0
const weeklyDownloads: ChartDataPoint[] = []
const latestRelease = releases[0]
releases.forEach((release: Release, index: number) => {
const downloads = release.assets.reduce(
(sum: number, asset: Asset) => sum + asset.download_count,
0
)
totalDownloads += downloads
if (index < 20) {
weeklyDownloads.push({
name: release.tag_name,
downloads: downloads,
})
}
})
const data: ReleaseData = {
totalDownloads,
latestRelease: {
tag: latestRelease.tag_name,
date: latestRelease.published_at,
downloads: latestRelease.assets.reduce(
(sum: number, asset: Asset) => sum + asset.download_count,
0
),
},
weeklyDownloads,
stars: repoInfo.stargazers_count,
forks: repoInfo.forks_count,
releases,
}
// Store cache specific to this repo
localStorage.setItem(
`github_release_data_${repo}_${
includePrerelease ? "with" : "without"
}_prerelease`,
JSON.stringify({
data,
timestamp: Date.now(),
})
)
return data
} catch (error) {
console.error("Error fetching GitHub data:", error)
throw error
}
}
const COLORS = [
"#0088FE",
"#00C49F",
"#FFBB28",
"#FF8042",
"#8884D8",
"#82CA9D",
"#A4DE6C",
"#D0ED57",
]
interface ReleaseDetailViewProps {
release: Release
repo: string
onClose: () => void
}
function ReleaseDetailView({ release, repo, onClose }: ReleaseDetailViewProps) {
const [chartType, setChartType] = useState("bar")
const [filterExecutables, setFilterExecutables] = useState(false)
const [executableCount, setExecutableCount] = useState(0)
const [zipCount, setZipCount] = useState(0)
const githubReleaseUrl = `https://github.com/${repo}/releases/tag/${release.tag_name}`
useEffect(() => {
if (filterExecutables) {
const executables = release.assets.filter(
(asset: Asset) =>
asset.name.endsWith(".dmg") ||
asset.name.endsWith(".exe") ||
asset.name.endsWith(".AppImage")
)
const zips = release.assets.filter((asset: Asset) =>
asset.name.endsWith(".zip")
)
setExecutableCount(
executables.reduce(
(sum: number, asset: Asset) => sum + asset.download_count,
0
)
)
setZipCount(
zips.reduce(
(sum: number, asset: Asset) => sum + asset.download_count,
0
)
)
} else {
setExecutableCount(0)
setZipCount(0)
}
}, [filterExecutables, release])
const filteredAssets = filterExecutables
? release.assets.filter(
(asset: Asset) =>
asset.name.endsWith(".dmg") ||
asset.name.endsWith(".exe") ||
asset.name.endsWith(".AppImage")
)
: release.assets
const chartData: ChartDataPoint[] = filteredAssets.map((asset: Asset) => ({
name: asset.name,
downloads: asset.download_count,
}))
const renderBarChart = () => (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" width={150} />
<Tooltip />
<Bar dataKey="downloads" fill="var(--color-downloads)" />
</BarChart>
</ResponsiveContainer>
)
const renderPieChart = () => (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
dataKey="downloads"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
fill="#8884d8"
label
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)
return (
<Card className="border-none shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
{release.tag_name} Details
<Button variant="outline" size="xs" asChild>
<a
href={githubReleaseUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<GitBranch className="h-3 w-3" />
View on GitHub
</a>
</Button>
</CardTitle>
<CardDescription>
Asset download statistics for this release
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<ToggleGroup
type="single"
value={chartType}
defaultValue="bar"
onValueChange={(value) => {
if (value) setChartType(value)
}}
>
<ToggleGroupItem value="bar" aria-label="Bar Chart">
<BarChartIcon className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="pie" aria-label="Pie Chart">
<PieChartIcon className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2 mb-4">
<Checkbox
id="executablesOnly"
checked={filterExecutables}
onCheckedChange={(checked) =>
setFilterExecutables(checked === true)
}
/>
<Label htmlFor="executablesOnly">Show Executables Only</Label>
</div>
{filterExecutables && (
<div className="mb-4">
<p>New Users(Executable): {executableCount}</p>
<p>Update Users(Zip): {zipCount}</p>
</div>
)}
<ChartContainer
config={{
downloads: {
label: "Downloads",
color: "hsl(var(--chart-1))",
},
}}
className="h-[400px] w-full"
>
{chartType === "bar" ? renderBarChart() : renderPieChart()}
</ChartContainer>
</CardContent>
</Card>
)
}
interface GitHubReleaseStatsProps {
repo?: string
includePrerelease?: boolean
}
export default function GitHubReleaseStats({
repo: initialRepo = "mayneyao/eidos",
includePrerelease = true,
}: GitHubReleaseStatsProps) {
const [releaseData, setReleaseData] = useState<ReleaseData | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null)
const [showPrerelease, setShowPrerelease] = useState(includePrerelease)
const [apiKey, setApiKey] = useState(() => {
return localStorage.getItem(API_KEY_STORAGE_KEY) || ""
})
const [tempApiKey, setTempApiKey] = useState(apiKey)
const [repos, setRepos] = useState(() => {
const savedRepos = localStorage.getItem(REPO_STORAGE_KEY)
return savedRepos ? JSON.parse(savedRepos) : [initialRepo]
})
const [currentRepo, setCurrentRepo] = useState(repos[0])
const [tempRepo, setTempRepo] = useState("")
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
useEffect(() => {
localStorage.setItem(REPO_STORAGE_KEY, JSON.stringify(repos))
}, [repos])
useEffect(() => {
const loadData = async () => {
if (!apiKey) {
setError(
"GitHub API key is not set. Please set your API key to fetch data."
)
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
// 为不同的 prerelease 设置使用不同的缓存key
const cacheKey = `github_release_data_${currentRepo}_${
showPrerelease ? "with" : "without"
}_prerelease`
const cachedData = localStorage.getItem(cacheKey)
if (cachedData) {
const { data, timestamp } = JSON.parse(cachedData)
const isCacheValid = Date.now() - timestamp < CACHE_EXPIRY
if (isCacheValid) {
setReleaseData(data)
setLoading(false)
return
}
}
const freshData = await fetchGitHubReleaseData(
currentRepo,
apiKey,
showPrerelease
)
setReleaseData(freshData)
setLoading(false)
} catch (err) {
setError((err as Error).message)
console.error(err)
setLoading(false)
}
}
loadData()
}, [currentRepo, apiKey, showPrerelease])
const handleReleaseClick = (releaseName: string) => {
const release = releaseData?.releases.find(
(r: Release) => r.tag_name === releaseName
)
setSelectedRelease(release || null)
}
const handleSaveApiKey = () => {
setApiKey(tempApiKey)
localStorage.setItem(API_KEY_STORAGE_KEY, tempApiKey)
setReleaseData(null) // Clear data to force refresh with new key
setLoading(true)
}
const handleAddRepo = () => {
if (tempRepo && !repos.includes(tempRepo)) {
setRepos([...repos, tempRepo])
setCurrentRepo(tempRepo)
setTempRepo("")
setReleaseData(null) // Clear data to force refresh with new repo
setLoading(true)
}
}
const handleRemoveRepo = (repoToRemove: string) => {
if (repos.length > 1) {
const updatedRepos = repos.filter((repo: string) => repo !== repoToRemove)
setRepos(updatedRepos)
if (currentRepo === repoToRemove) {
setCurrentRepo(updatedRepos[0])
setReleaseData(null) // Clear data to force refresh with new repo
setLoading(true)
}
}
}
const handleRefreshCache = () => {
// 清除当前仓库的所有相关缓存数据
localStorage.removeItem(
`github_release_data_${currentRepo}_with_prerelease`
)
localStorage.removeItem(
`github_release_data_${currentRepo}_without_prerelease`
)
// 重置状态并重新加载数据
setReleaseData(null)
setSelectedRelease(null)
setLoading(true)
}
if (!apiKey || (error && !releaseData)) {
return (
<Card className="border-none shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>GitHub Release Statistics</CardTitle>
<CardDescription>
Set your GitHub API key to view statistics
</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Key className="h-4 w-4 mr-2" />
Set API Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set GitHub API Key</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
value={tempApiKey}
onChange={(e) => setTempApiKey(e.target.value)}
placeholder="Enter your GitHub API key"
type="password"
/>
<div className="flex justify-end">
<Button onClick={handleSaveApiKey}>Save</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8 text-muted-foreground">
<AlertCircle className="h-6 w-6 mr-2" />
{error ||
"Please set your GitHub API key to fetch release statistics."}
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-8">
<Card className="border-none shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{currentRepo} Release Statistics</CardTitle>
<CardDescription>
Overview of release downloads and repository information
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Key className="h-4 w-4 mr-2" />
Set API Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set GitHub API Key</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
value={tempApiKey}
onChange={(e) => setTempApiKey(e.target.value)}
placeholder="Enter your GitHub API key"
type="password"
/>
<div className="flex justify-end">
<Button onClick={handleSaveApiKey}>Save</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="h-4 w-4 mr-2" />
Manage Repositories
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage GitHub Repositories</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col space-y-2">
<Label>Current Repositories</Label>
{repos.map((repo: string) => (
<div
key={repo}
className="flex items-center justify-between p-2 bg-muted rounded-md"
>
<span
className={`cursor-pointer ${
repo === currentRepo ? "font-bold" : ""
}`}
onClick={() => {
setCurrentRepo(repo)
setReleaseData(null)
setLoading(true)
}}
>
{repo}
</span>
{repo !== currentRepo && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveRepo(repo)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<div className="flex flex-col space-y-2">
<Label>Add New Repository</Label>
<Input
value={tempRepo}
onChange={(e) => setTempRepo(e.target.value)}
placeholder="Enter repository (e.g., mayneyao/eidos)"
/>
<div className="flex justify-end">
<Button
onClick={handleAddRepo}
disabled={!tempRepo || repos.includes(tempRepo)}
>
Add Repository
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
size="sm"
onClick={handleRefreshCache}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{loading
? Array(4)
.fill(0)
.map((_, index) => (
<Skeleton key={index} className="h-24 w-full" />
))
: releaseData && (
<>
<div className="flex flex-col items-center justify-center p-4 bg-muted rounded-lg">
<Download className="h-6 w-6 mb-2 text-primary" />
<div className="text-2xl font-bold">
{releaseData.totalDownloads.toLocaleString()}
</div>
<div className="text-sm text-muted-foreground">
Total Downloads
</div>
</div>
<div className="flex flex-col items-center justify-center p-4 bg-muted rounded-lg">
<Tag className="h-6 w-6 mb-2 text-primary" />
<div className="text-2xl font-bold">
{releaseData.latestRelease.tag}
</div>
<div className="text-sm text-muted-foreground">
Latest Release
</div>
</div>
<div className="flex flex-col items-center justify-center p-4 bg-muted rounded-lg">
<Star className="h-6 w-6 mb-2 text-primary" />
<div className="text-2xl font-bold">
{releaseData.stars.toLocaleString()}
</div>
<div className="text-sm text-muted-foreground">Stars</div>
</div>
<div className="flex flex-col items-center justify-center p-4 bg-muted rounded-lg">
<GitBranch className="h-6 w-6 mb-2 text-primary" />
<div className="text-2xl font-bold">
{releaseData.forks.toLocaleString()}
</div>
<div className="text-sm text-muted-foreground">Forks</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
<Card className="border-none shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Download Trends</CardTitle>
<CardDescription>
Release download statistics for recent releases
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPrerelease"
checked={showPrerelease}
onCheckedChange={(checked) => setShowPrerelease(checked === true)}
/>
<Label htmlFor="showPrerelease">Include Prerelease</Label>
<span className="text-xs text-muted-foreground">
({showPrerelease ? "Enabled" : "Disabled"})
</span>
</div>
</CardHeader>
<CardContent className="border-none">
{loading ? (
<Skeleton className="h-[400px] w-full" />
) : (
releaseData && (
<ChartContainer
config={{
downloads: {
label: "Downloads",
color: "hsl(var(--chart-1))",
},
}}
className="h-[400px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={
isMobile
? releaseData.weeklyDownloads.slice(0, 10)
: releaseData.weeklyDownloads.slice(0, 15)
}
margin={{ top: 20, right: 30, left: 20, bottom: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="name"
angle={-45}
textAnchor="end"
height={60}
interval={0}
tick={{ fontSize: isMobile ? 10 : 12 }}
/>
<YAxis />
<Tooltip />
<Bar
dataKey="downloads"
fill="var(--color-downloads)"
onClick={(data: any) =>
data?.name && handleReleaseClick(data.name)
}
/>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
)
)}
</CardContent>
</Card>
{selectedRelease && (
<ReleaseDetailView
release={selectedRelease}
repo={currentRepo}
onClose={() => setSelectedRelease(null)}
/>
)}
</div>
)
}