From 513bf707b02cab419b8b8551d36daad1b859071c Mon Sep 17 00:00:00 2001 From: Suyi Date: Wed, 5 Aug 2026 23:42:04 +0800 Subject: [PATCH 01/24] feat: add CNPM registry browser Add npmmirror registry browser at /cnpm with package search, landing page, and package detail views (README, versions, files, deps, download trends). Data is fetched browser-direct from registry.npmmirror.com. --- apps/web/app/components/Layout.tsx | 11 + apps/web/app/components/cnpm/DepsView.tsx | 78 ++++ apps/web/app/components/cnpm/DownloadCard.tsx | 109 +++++ apps/web/app/components/cnpm/FilesView.tsx | 270 +++++++++++++ .../app/components/cnpm/MaintainersCard.tsx | 51 +++ .../web/app/components/cnpm/NpmSearchForm.tsx | 66 ++++ apps/web/app/components/cnpm/PkgHeader.tsx | 141 +++++++ apps/web/app/components/cnpm/PkgSidebar.tsx | 70 ++++ apps/web/app/components/cnpm/PkgTabs.tsx | 48 +++ .../web/app/components/cnpm/RecentVisited.tsx | 38 ++ .../web/app/components/cnpm/RegistryGuide.tsx | 90 +++++ .../web/app/components/cnpm/RegistryStats.tsx | 40 ++ apps/web/app/components/cnpm/VersionTable.tsx | 78 ++++ apps/web/app/components/ui/chart.tsx | 371 ++++++++++++++++++ apps/web/app/lib/registry/client.ts | 124 ++++++ apps/web/app/lib/registry/gravatar.ts | 23 ++ apps/web/app/lib/registry/parse.test.ts | 91 +++++ apps/web/app/lib/registry/parse.ts | 93 +++++ apps/web/app/lib/registry/types.ts | 100 +++++ apps/web/app/lib/registry/use-recent.ts | 47 +++ apps/web/app/routes.ts | 3 + apps/web/app/routes/cnpm.pkg.tsx | 151 +++++++ apps/web/app/routes/cnpm.search.tsx | 177 +++++++++ apps/web/app/routes/cnpm.tsx | 66 ++++ apps/web/package.json | 1 + apps/web/tests/CnpmRegistry.test.tsx | 171 ++++++++ pnpm-lock.yaml | 261 +++++++++++- 27 files changed, 2765 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/components/cnpm/DepsView.tsx create mode 100644 apps/web/app/components/cnpm/DownloadCard.tsx create mode 100644 apps/web/app/components/cnpm/FilesView.tsx create mode 100644 apps/web/app/components/cnpm/MaintainersCard.tsx create mode 100644 apps/web/app/components/cnpm/NpmSearchForm.tsx create mode 100644 apps/web/app/components/cnpm/PkgHeader.tsx create mode 100644 apps/web/app/components/cnpm/PkgSidebar.tsx create mode 100644 apps/web/app/components/cnpm/PkgTabs.tsx create mode 100644 apps/web/app/components/cnpm/RecentVisited.tsx create mode 100644 apps/web/app/components/cnpm/RegistryGuide.tsx create mode 100644 apps/web/app/components/cnpm/RegistryStats.tsx create mode 100644 apps/web/app/components/cnpm/VersionTable.tsx create mode 100644 apps/web/app/components/ui/chart.tsx create mode 100644 apps/web/app/lib/registry/client.ts create mode 100644 apps/web/app/lib/registry/gravatar.ts create mode 100644 apps/web/app/lib/registry/parse.test.ts create mode 100644 apps/web/app/lib/registry/parse.ts create mode 100644 apps/web/app/lib/registry/types.ts create mode 100644 apps/web/app/lib/registry/use-recent.ts create mode 100644 apps/web/app/routes/cnpm.pkg.tsx create mode 100644 apps/web/app/routes/cnpm.search.tsx create mode 100644 apps/web/app/routes/cnpm.tsx create mode 100644 apps/web/tests/CnpmRegistry.test.tsx diff --git a/apps/web/app/components/Layout.tsx b/apps/web/app/components/Layout.tsx index 4d990fa..6b7af7b 100644 --- a/apps/web/app/components/Layout.tsx +++ b/apps/web/app/components/Layout.tsx @@ -11,6 +11,7 @@ import { User, Settings, LogOut, + Boxes, } from "lucide-react"; import { ThemeToggle } from "./ThemeToggle"; import { useNavTransition } from "./NavProgress"; @@ -102,6 +103,10 @@ export function Header() { {z.name} ))} + + + CNPM + API @@ -317,6 +322,12 @@ function MobileNavTrigger({ visibleZones = [] }: { visibleZones?: any[] }) { > API + + CNPM + 该版本没有依赖信息

; + } + + const groups = GROUPS.filter((group) => { + const deps = versionData[group.key]; + return deps && Object.keys(deps).length > 0; + }); + + if (groups.length === 0) { + return

该版本没有任何依赖

; + } + + return ( +
+ {groups.map((group) => { + const deps = versionData[group.key]!; + const entries = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)); + return ( +
+

+ {group.label} + {entries.length} +

+ + + + 名称 + 版本范围 + + + + {entries.map(([pkg, spec]) => ( + + + + {pkg} + + + {spec} + + ))} + +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/app/components/cnpm/DownloadCard.tsx b/apps/web/app/components/cnpm/DownloadCard.tsx new file mode 100644 index 0000000..157d1b2 --- /dev/null +++ b/apps/web/app/components/cnpm/DownloadCard.tsx @@ -0,0 +1,109 @@ +import { getDownloads, useRegistryQuery } from "~/lib/registry/client"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "~/components/ui/chart"; +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty"; + +const chartConfig = { + downloads: { + label: "下载量", + color: "var(--primary)", + }, +} satisfies ChartConfig; + +export function DownloadCard({ + pkgName, + version, + range = 7, +}: { + pkgName: string; + version: string; + range?: number; +}) { + const { data, loading } = useRegistryQuery( + () => getDownloads(pkgName, range), + [pkgName, version, range], + ); + + if (loading) { + return ( + + + 下载量 + + + + + + + ); + } + + const points = data?.downloads || []; + const total = points.reduce((sum, point) => sum + point.downloads, 0); + + if (points.length === 0) { + return ( + + + 下载量 + + + + 暂无数据 + 该包暂无下载数据 + + + + ); + } + + return ( + + + + 近 {range} 天下载 + + {total.toLocaleString("en-US")} + + + + + + + + + + + + + + value.slice(5)} + /> + } /> + + + + + + ); +} diff --git a/apps/web/app/components/cnpm/FilesView.tsx b/apps/web/app/components/cnpm/FilesView.tsx new file mode 100644 index 0000000..f042e03 --- /dev/null +++ b/apps/web/app/components/cnpm/FilesView.tsx @@ -0,0 +1,270 @@ +import { useCallback, useState } from "react"; +import { useSearchParams } from "react-router"; +import { ChevronDown, ChevronRight, File as FileIcon, Folder } from "lucide-react"; +import { getDir, getFileContent, useRegistryQuery } from "~/lib/registry/client"; +import type { RegistryFile } from "~/lib/registry/types"; +import { cn } from "~/lib/utils"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import hljs from "highlight.js/lib/common"; + +export function FilesView({ pkgName, spec }: { pkgName: string; spec: string }) { + const [params, setParams] = useSearchParams(); + const selectedPath = params.get("path") || ""; + const [dirChildren, setDirChildren] = useState>({}); + const [expanded, setExpanded] = useState>({}); + const [dirLoading, setDirLoading] = useState>({}); + const [dirError, setDirError] = useState(null); + + const { + data: root, + error: rootError, + loading: rootLoading, + retry, + } = useRegistryQuery(() => getDir(pkgName, spec, ""), [pkgName, spec]); + + const loadDir = useCallback( + async (path: string) => { + if (dirChildren[path] || dirLoading[path]) return; + setDirLoading((prev) => ({ ...prev, [path]: true })); + setDirError(null); + try { + const res = await getDir(pkgName, spec, path); + setDirChildren((prev) => ({ ...prev, [path]: res.files || [] })); + } catch (err) { + setDirChildren((prev) => ({ ...prev, [path]: [] })); + setDirError(err instanceof Error ? err.message : "目录加载失败"); + } finally { + setDirLoading((prev) => ({ ...prev, [path]: false })); + } + }, + [pkgName, spec, dirChildren, dirLoading], + ); + + const toggleDir = useCallback( + (path: string) => { + setExpanded((prev) => { + const next = !prev[path]; + if (next) void loadDir(path); + return { ...prev, [path]: next }; + }); + }, + [loadDir], + ); + + const selectFile = useCallback( + (path: string) => { + const next = new URLSearchParams(params); + if (path) next.set("path", path); + else next.delete("path"); + setParams(next, { replace: true }); + }, + [params, setParams], + ); + + if (rootLoading) { + return ( +
+ + +
+ ); + } + + if (rootError) { + return ( + + + 产物预览失败 + + + + ); + } + + if (!root?.files || root.files.length === 0) { + return ( + + 没有文件 + 该版本没有可浏览的文件 + + ); + } + + return ( +
+
+ +
+
+ {selectedPath ? ( + + ) : ( +
+

选择一个文件查看内容

+
+ )} +
+ {dirError && ( +

+ {dirError} +

+ )} +
+ ); +} + +function FileNode({ + entries, + depth, + selectedPath, + onSelect, + onToggleDir, + dirChildren, + expanded, + dirLoading, +}: { + entries: RegistryFile[]; + depth: number; + selectedPath: string; + onSelect: (path: string) => void; + onToggleDir: (path: string) => void; + dirChildren: Record; + expanded: Record; + dirLoading: Record; +}) { + const sorted = [...entries].sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.path.localeCompare(b.path); + }); + + return ( +
    + {sorted.map((entry) => { + if (entry.type === "directory") { + const isExpanded = !!expanded[entry.path]; + const children = dirChildren[entry.path] ?? entry.files ?? []; + const isLoading = !!dirLoading[entry.path]; + return ( +
  • + + {isExpanded && children.length > 0 && ( + + )} +
  • + ); + } + const isSelected = entry.path === selectedPath; + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function FileViewer({ pkgName, spec, path }: { pkgName: string; spec: string; path: string }) { + const { data: content, error, loading, retry } = useRegistryQuery( + () => getFileContent(pkgName, spec, path), + [pkgName, spec, path], + ); + + if (loading) { + return ; + } + + if (error) { + return ( + + + 文件加载失败 + + + + ); + } + + return ( +
+
+        
+      
+
+ ); +} + +function highlighted(code: string) { + try { + return hljs.highlightAuto(code).value; + } catch { + return escapeHtml(code); + } +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function basename(path: string) { + const normalized = path.replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} diff --git a/apps/web/app/components/cnpm/MaintainersCard.tsx b/apps/web/app/components/cnpm/MaintainersCard.tsx new file mode 100644 index 0000000..6e363c2 --- /dev/null +++ b/apps/web/app/components/cnpm/MaintainersCard.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import type { RegistryManifest } from "~/lib/registry/types"; +import { gravatarHash, gravatarUrl, initials } from "~/lib/registry/gravatar"; + +type Maintainer = NonNullable[number]; + +export function MaintainersCard({ maintainers }: { maintainers: Maintainer[] }) { + return ( + + + 维护者 + + + {maintainers.map((maintainer) => ( + + ))} + + + ); +} + +function MaintainerRow({ maintainer }: { maintainer: Maintainer }) { + const [hash, setHash] = useState(null); + + useEffect(() => { + let cancelled = false; + gravatarHash(maintainer.email).then((value) => { + if (!cancelled) setHash(value); + }); + return () => { + cancelled = true; + }; + }, [maintainer.email]); + + return ( +
+ + + {initials(maintainer.name)} + +
+
{maintainer.name}
+ {maintainer.email && ( +
{maintainer.email}
+ )} +
+
+ ); +} diff --git a/apps/web/app/components/cnpm/NpmSearchForm.tsx b/apps/web/app/components/cnpm/NpmSearchForm.tsx new file mode 100644 index 0000000..4842f6f --- /dev/null +++ b/apps/web/app/components/cnpm/NpmSearchForm.tsx @@ -0,0 +1,66 @@ +import { useNavigate } from "react-router"; +import { useState } from "react"; +import { Search as SearchIcon } from "lucide-react"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "~/components/ui/input-group"; +import { cn } from "~/lib/utils"; + +export function NpmSearchForm({ + initialValue = "", + autoFocus = false, + size = "default", + className, +}: { + initialValue?: string; + autoFocus?: boolean; + size?: "default" | "lg"; + className?: string; +}) { + const navigate = useNavigate(); + const [value, setValue] = useState(initialValue); + const large = size === "lg"; + + return ( +
{ + event.preventDefault(); + const keyword = value.trim(); + if (keyword) navigate(`/cnpm/search?q=${encodeURIComponent(keyword)}`); + }} + > + + + + + setValue(event.target.value)} + placeholder="搜索 npm 包,如 react、@babel/core..." + aria-label="搜索 npm 包" + className={cn("h-full", large && "text-base")} + /> + + + {large && } + 搜索 + + + +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgHeader.tsx b/apps/web/app/components/cnpm/PkgHeader.tsx new file mode 100644 index 0000000..f8af872 --- /dev/null +++ b/apps/web/app/components/cnpm/PkgHeader.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { Check, Copy, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { sortVersions, useVersionTags, type PkgTab } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; +import { PkgTabs } from "./PkgTabs"; + +function repoUrl(repository: RegistryManifest["repository"]) { + if (!repository) return undefined; + const url = typeof repository === "string" ? repository : repository.url; + if (!url) return undefined; + if (/^git(\+ssh)?:\/\//.test(url)) { + return url.replace(/^git(\+ssh)?:\/\//, "https://").replace(/\.git$/, ""); + } + if (/^git@github\.com:(.+)$/.test(url)) { + return `https://github.com/${url.replace(/^git@github\.com:/, "").replace(/\.git$/, "")}`; + } + if (url.startsWith("http")) return url.replace(/\.git$/, ""); + return undefined; +} + +export function PkgHeader({ + manifest, + version, + active, + onVersionChange, +}: { + manifest: RegistryManifest; + version: string; + active: PkgTab; + onVersionChange: (version: string) => void; +}) { + const versions = sortVersions(manifest.versions); + const tags = useVersionTags(manifest); + const [copied, setCopied] = useState(false); + const installCommand = `npm install ${manifest.name}@${version}`; + const repo = repoUrl(manifest.repository); + + const handleVersionChange = (value: string | null) => { + if (value) onVersionChange(value); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(installCommand); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + // clipboard unavailable + } + }; + + return ( +
+
+
+
+ +
+
+
+

+ {manifest.name} + {version} +

+ {tags[version]?.length ? ( + + {tags[version].join(", ")} + + ) : null} +
+ {manifest.description && ( +

{manifest.description}

+ )} +
+ {manifest.license && ( + {manifest.license} + )} + {repo && ( + + 源码 + + )} + {manifest.homepage && ( + + 主页 + + + )} +
+
+
+
+ + +
+
+ +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgSidebar.tsx b/apps/web/app/components/cnpm/PkgSidebar.tsx new file mode 100644 index 0000000..033f0c2 --- /dev/null +++ b/apps/web/app/components/cnpm/PkgSidebar.tsx @@ -0,0 +1,70 @@ +import { Download, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { DownloadCard } from "./DownloadCard"; +import { MaintainersCard } from "./MaintainersCard"; +import { formatBytes } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; + +function repoUrl(repository: RegistryManifest["repository"]) { + if (!repository) return undefined; + const url = typeof repository === "string" ? repository : repository.url; + if (!url) return undefined; + if (/^git(\+ssh)?:\/\//.test(url)) { + return url.replace(/^git(\+ssh)?:\/\//, "https://").replace(/\.git$/, ""); + } + if (/^git@github\.com:(.+)$/.test(url)) { + return `https://github.com/${url.replace(/^git@github\.com:/, "").replace(/\.git$/, "")}`; + } + if (url.startsWith("http")) return url.replace(/\.git$/, ""); + return undefined; +} + +export function PkgSidebar({ manifest, version }: { manifest: RegistryManifest; version: string }) { + const repo = repoUrl(manifest.repository); + const dist = manifest.versions?.[version]?.dist; + const links: Array<{ label: string; href?: string; icon: React.ReactNode } | null> = [ + { label: "仓库", href: repo, icon: }, + { label: "主页", href: manifest.homepage, icon: }, + { label: "npmjs.com", href: `https://www.npmjs.com/package/${manifest.name}`, icon: }, + { label: "unpkg", href: `https://unpkg.com/${manifest.name}@${version}`, icon: }, + dist?.tarball + ? { + label: `tarball${dist.size !== undefined ? ` · ${formatBytes(dist.size)}` : ""}`, + href: dist.tarball, + icon: , + } + : null, + ]; + const visibleLinks = links.filter( + (link): link is { label: string; href: string; icon: React.ReactNode } => Boolean(link && link.href), + ); + + return ( +
+ + {manifest.maintainers && manifest.maintainers.length > 0 && ( + + )} + + + 资源信息 + + + {visibleLinks.map((link) => ( + + {link.icon} + {link.label} + + + ))} + + +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgTabs.tsx b/apps/web/app/components/cnpm/PkgTabs.tsx new file mode 100644 index 0000000..4df360b --- /dev/null +++ b/apps/web/app/components/cnpm/PkgTabs.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from "react-router"; +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import type { PkgTab } from "~/lib/registry/parse"; + +const TABS: Array<{ key: PkgTab; label: string }> = [ + { key: "home", label: "首页" }, + { key: "versions", label: "版本" }, + { key: "files", label: "文件" }, + { key: "deps", label: "依赖" }, + { key: "trends", label: "趋势" }, +]; + +export function PkgTabs({ + name, + active, + version, +}: { + name: string; + active: PkgTab; + version?: string; +}) { + const navigate = useNavigate(); + const versionQuery = version ? `?version=${encodeURIComponent(version)}` : ""; + + const handleChange = (value: string | number) => { + const tab = String(value) as PkgTab; + navigate( + tab === "home" + ? `/cnpm/pkg/${name}${versionQuery}` + : `/cnpm/pkg/${name}/${tab}${versionQuery}`, + ); + }; + + return ( + + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + ); +} diff --git a/apps/web/app/components/cnpm/RecentVisited.tsx b/apps/web/app/components/cnpm/RecentVisited.tsx new file mode 100644 index 0000000..7d2dceb --- /dev/null +++ b/apps/web/app/components/cnpm/RecentVisited.tsx @@ -0,0 +1,38 @@ +import { Link } from "react-router"; +import { X } from "lucide-react"; +import { useRecentVisited } from "~/lib/registry/use-recent"; +import { Button } from "~/components/ui/button"; + +export function RecentVisited() { + const { recent, removeRecent } = useRecentVisited(); + if (recent.length === 0) return null; + + return ( +
+ 最近访问 + {recent.map((name) => ( + + + {name} + + + + ))} +
+ ); +} diff --git a/apps/web/app/components/cnpm/RegistryGuide.tsx b/apps/web/app/components/cnpm/RegistryGuide.tsx new file mode 100644 index 0000000..cc53f5b --- /dev/null +++ b/apps/web/app/components/cnpm/RegistryGuide.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Check, Copy, ExternalLink } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; + +const STEPS = [ + { + title: "临时使用", + description: "单次安装时指定镜像源,不影响全局配置", + command: "npm install react --registry=https://registry.npmmirror.com", + }, + { + title: "全局配置", + description: "将默认 registry 指向 npmmirror,之后安装自动走镜像", + command: "npm config set registry https://registry.npmmirror.com", + }, + { + title: "验证配置", + description: "确认当前 registry 是否为镜像地址", + command: "npm config get registry", + }, +]; + +const REGISTRY_URL = "https://registry.npmmirror.com"; + +function CopyCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + // clipboard unavailable + } + }; + + return ( +
+ + {command} + + +
+ ); +} + +export function RegistryGuide() { + return ( + + + 使用 npmmirror 镜像 + + 通过国内镜像源加速 npm 安装,官方镜像地址: + + {REGISTRY_URL} + + + + + + {STEPS.map((step, index) => ( +
+
+ {index + 1}. +

{step.title}

+
+

{step.description}

+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/components/cnpm/RegistryStats.tsx b/apps/web/app/components/cnpm/RegistryStats.tsx new file mode 100644 index 0000000..7a2fe8a --- /dev/null +++ b/apps/web/app/components/cnpm/RegistryStats.tsx @@ -0,0 +1,40 @@ +import { getRegistryStats, useRegistryQuery } from "~/lib/registry/client"; +import { formatCompactNumber } from "~/lib/registry/parse"; +import { Skeleton } from "~/components/ui/skeleton"; + +export function RegistryStats() { + const { data, loading } = useRegistryQuery(getRegistryStats, []); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (!data || !data.doc_count) { + return null; + } + + const items = [ + { label: "包数量", value: formatCompactNumber(data.doc_count) }, + { label: "本周下载", value: formatCompactNumber(data.download?.thisweek) }, + { label: "今日下载", value: formatCompactNumber(data.download?.today) }, + ]; + + return ( +
+ {items.map((item) => ( +
+ + {item.value} + + {item.label} +
+ ))} +
+ ); +} diff --git a/apps/web/app/components/cnpm/VersionTable.tsx b/apps/web/app/components/cnpm/VersionTable.tsx new file mode 100644 index 0000000..8504b20 --- /dev/null +++ b/apps/web/app/components/cnpm/VersionTable.tsx @@ -0,0 +1,78 @@ +import { Link } from "react-router"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { Badge } from "~/components/ui/badge"; +import { sortVersions, useVersionTags } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; + +function formatDate(value: number | string | undefined) { + if (!value) return "-"; + const date = new Date(Number(value) || String(value)); + if (Number.isNaN(date.getTime())) return "-"; + return date.toISOString().slice(0, 10); +} + +export function VersionTable({ manifest, version }: { manifest: RegistryManifest; version: string }) { + const versions = sortVersions(manifest.versions); + const tags = useVersionTags(manifest); + + return ( + + + + 版本 + 标签 + 发布时间 + 大小 + + + + {versions.map((item) => ( + + + + {item.version} + + + +
+ {tags[item.version]?.map((tag) => ( + + {tag} + + ))} +
+
+ + {formatDate(item.publish_time ?? item._cnpmcore_publish_time)} + + + {item.dist?.size !== undefined + ? formatSize(item.dist.size) + : item.dist?.unpackedSize !== undefined + ? formatSize(item.dist.unpackedSize) + : "-"} + +
+ ))} +
+
+ ); +} + +function formatSize(bytes: number) { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, Math.min(index, units.length - 1)); + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[Math.min(index, units.length - 1)]}`; +} diff --git a/apps/web/app/components/ui/chart.tsx b/apps/web/app/components/ui/chart.tsx new file mode 100644 index 0000000..5381fcd --- /dev/null +++ b/apps/web/app/components/ui/chart.tsx @@ -0,0 +1,371 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "~/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +