Eidos
github-release-stats icon

github-release-stats

By: Mayne

Install Latest (v0.0.4)

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>
  )
}

Information

Author
Mayne
Type
m_block
Latest Version
0.0.4
Last Updated
05/30/2025
Published
05/18/2025

Version History

  • v0.0.4 05/30/2025
  • v0.0.3 05/18/2025
  • v0.0.2 05/18/2025
  • v0.0.1 05/18/2025