chore: remove unused code and tooling

This commit is contained in:
2026-04-18 19:15:22 +08:00
parent 4b62f09d65
commit ba3154af59
21 changed files with 50 additions and 2227 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+1 -16
View File
@@ -7,41 +7,26 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "vitest", "test": "vitest"
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.1.0", "@prisma/client": "^6.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-navigation-menu": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next": "15.1.11", "next": "15.1.11",
"next-intl": "^4.0.2", "next-intl": "^4.0.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-shiki": "^0.0.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.1.11", "eslint-config-next": "15.1.11",
-32
View File
@@ -1,32 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
const PORT = Number(process.env.PLAYWRIGHT_PORT || 3100)
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`
export default defineConfig({
testDir: './e2e',
timeout: 120_000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: `pnpm dev --port ${PORT}`,
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+17 -1644
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -13,11 +13,10 @@ export async function generateStaticParams() {
} }
export async function generateMetadata({ export async function generateMetadata({
params params: _params
}: { }: {
params: Promise<{ locale: string }> params: Promise<{ locale: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations('home') const t = await getTranslations('home')
return { return {
@@ -43,7 +42,6 @@ export default async function LocaleLayout({
// Get translations // Get translations
const t = await getTranslations('layout') const t = await getTranslations('layout')
const tNav = await getTranslations('navigation') const tNav = await getTranslations('navigation')
const tHome = await getTranslations('home')
const messages = await getMessages() const messages = await getMessages()
return ( return (
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
+1 -1
View File
@@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma";
* *
* 根据项目的 slug 获取项目详情 * 根据项目的 slug 获取项目详情
*/ */
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
try { try {
const { slug } = await params; const { slug } = await params;
+16 -2
View File
@@ -2,8 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { POST } from "./route"; import { POST } from "./route";
type MaintenanceRouteTxMock = {
tag: {
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
projectTag: {
findMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
};
const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => { const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
const tx = { const tx: MaintenanceRouteTxMock = {
tag: { tag: {
findMany: vi.fn(), findMany: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
@@ -18,7 +32,7 @@ const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
}; };
return { return {
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)), transactionMock: vi.fn(async (callback: (tx: MaintenanceRouteTxMock) => unknown) => callback(tx)),
revalidatePathMock: vi.fn(), revalidatePathMock: vi.fn(),
txMock: tx, txMock: tx,
}; };
+4 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { executeTagMaintenance, TagMaintenanceApiError } from "./service"; import { executeTagMaintenance } from "./service";
function createTxMock() { function createTxMock() {
return { return {
@@ -34,7 +34,7 @@ describe("executeTagMaintenance", () => {
tx.projectTag.createMany.mockResolvedValue({ count: 2 }); tx.projectTag.createMany.mockResolvedValue({ count: 2 });
tx.tag.deleteMany.mockResolvedValue({ count: 2 }); tx.tag.deleteMany.mockResolvedValue({ count: 2 });
const result = await executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], { const result = await executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
updates: [], updates: [],
merges: [ merges: [
{ {
@@ -70,11 +70,11 @@ describe("executeTagMaintenance", () => {
tx.tag.findMany.mockResolvedValue([]); tx.tag.findMany.mockResolvedValue([]);
await expect( await expect(
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], { executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
updates: [{ tagId: "missing-tag", nameEn: "Missing" }], updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
merges: [], merges: [],
}) })
).rejects.toMatchObject<TagMaintenanceApiError>({ ).rejects.toMatchObject({
status: 400, status: 400,
message: "Validation error", message: "Validation error",
}); });
+10 -3
View File
@@ -2,9 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { POST } from "./route"; import { POST } from "./route";
type ResetProjectsRouteTxMock = {
projectTag: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
};
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } = const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
vi.hoisted(() => { vi.hoisted(() => {
const tx = { const tx: ResetProjectsRouteTxMock = {
projectTag: { projectTag: {
deleteMany: vi.fn(), deleteMany: vi.fn(),
createMany: vi.fn(), createMany: vi.fn(),
@@ -13,7 +20,7 @@ const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFin
return { return {
revalidatePathMock: vi.fn(), revalidatePathMock: vi.fn(),
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)), transactionMock: vi.fn(async (callback: (tx: ResetProjectsRouteTxMock) => unknown) => callback(tx)),
txMock: tx, txMock: tx,
projectFindManyMock: vi.fn(), projectFindManyMock: vi.fn(),
tagFindManyMock: vi.fn(), tagFindManyMock: vi.fn(),
@@ -88,7 +95,7 @@ describe("POST /api/tags/reset-projects", () => {
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => { it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
const payload = buildValidPayload(validApiKey); const payload = buildValidPayload(validApiKey);
payload.projects[0].selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = []; payload.projects[0]!.selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
const response = await POST(buildRequest(payload)); const response = await POST(buildRequest(payload));
const json = await response.json(); const json = await response.json();
-1
View File
@@ -2,7 +2,6 @@
import Link from 'next/link' import Link from 'next/link'
const locales = ['zh', 'en'] as const
const localeNames: Record<string, string> = { const localeNames: Record<string, string> = {
zh: '中文', zh: '中文',
en: 'EN' en: 'EN'
@@ -1,56 +0,0 @@
import { ExternalLink, LinkType } from '@prisma/client'
import { getTranslations } from 'next-intl/server'
interface ExternalLinkCardProps {
links: ExternalLink[]
locale: string
}
export async function ExternalLinkCard({ links, locale }: ExternalLinkCardProps) {
const t = await getTranslations('project')
if (links.length === 0) {
return null
}
// Build type map for all link types
const typeMap: Record<LinkType, string> = {
WEBSITE: t('website'),
GITHUB: t('github'),
HUGGINGFACE: t('huggingface'),
PAPER: t('paper'),
}
// Pre-resolve all link names
const linkItems = await Promise.all(
links.map(async (link) => ({
...link,
displayName: link.title || typeMap[link.type]
}))
)
return (
<div className="border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">{t('externalLinks')}</h3>
<div className="space-y-3">
{linkItems.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 border rounded-lg hover:bg-secondary transition-colors"
>
<div>
<div className="font-medium">
{link.displayName}
</div>
<div className="text-sm text-muted-foreground">{link.url}</div>
</div>
<span className="text-primary"></span>
</a>
))}
</div>
</div>
)
}
-74
View File
@@ -1,74 +0,0 @@
import Image from 'next/image'
interface GitHubBadgesProps {
starsUrl?: string | null
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizes = {
sm: { width: 80, height: 20 },
md: { width: 100, height: 20 },
lg: { width: 120, height: 20 }
}
/**
* GitHub Stars 徽章组件 - 显示 GitHub Stars 数量
* 适用于项目详情页
*/
export function GitHubBadges({
starsUrl,
size = 'md',
className = ''
}: GitHubBadgesProps) {
if (!starsUrl) {
return null
}
const { width, height } = sizes[size]
return (
<div className={className}>
<Image
src={starsUrl}
alt="GitHub Stars"
width={width}
height={height}
unoptimized
className="hover:opacity-80 transition-opacity rounded"
/>
</div>
)
}
/**
* GitHub Stars 紧凑型组件 - 用于项目卡片
* 在较小的空间内显示 GitHub Stars 数量
*/
export function GitHubStatsCompact({
starsUrl,
className = ''
}: {
starsUrl?: string | null
className?: string
}) {
if (!starsUrl) {
return null
}
return (
<div className={`flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 ${className}`}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
</svg>
<Image
src={starsUrl}
alt="Stars"
width={60}
height={20}
unoptimized
className="rounded"
/>
</div>
)
}
-3
View File
@@ -116,9 +116,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
{/* Full content with Markdown rendering */} {/* Full content with Markdown rendering */}
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} /> <MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
</article> </article>
{/* Share and Feedback Section - Temporarily disabled for debugging */}
{/* <ShareButtons displayName={displayName} /> */}
</> </>
) )
} }
@@ -37,7 +37,6 @@ function getLinkIcon(type: string): string {
export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) { export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
const t = await getTranslations('project') const t = await getTranslations('project')
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
// 获取 GitHub 统计数据 // 获取 GitHub 统计数据
const githubInfo = getGitHubInfoFromLinks(project.links) const githubInfo = getGitHubInfoFromLinks(project.links)
-56
View File
@@ -1,56 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
interface ShareButtonsProps {
displayName: string
}
export function ShareButtons({ displayName }: ShareButtonsProps) {
const t = useTranslations('project')
const handleShare = () => {
const url = encodeURIComponent(window.location.href)
const text = encodeURIComponent(t('shareTweetText', { name: displayName }))
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
}
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href)
}
return (
<div className="mt-12 pt-8 border-t border-gray-300 dark:border-gray-700 flex flex-col sm:flex-row justify-between items-center gap-6">
<div className="flex gap-4">
<button
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={t('shareOnX')}
onClick={handleShare}
id="share-button"
name="share"
>
<span className="material-icons text-lg">share</span>
</button>
<button
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={t('copyLink')}
onClick={handleCopyLink}
id="copy-link-button"
name="copyLink"
>
<span className="material-icons text-lg">link</span>
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-display font-bold text-gray-500">{t('feedbackQuestion')}</span>
<button
className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
id="feedback-button"
name="feedback"
>
{t('feedbackYes')}
</button>
</div>
</div>
)
}
-131
View File
@@ -1,131 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
interface TagCloudProps {
tags: Array<{
id: string
name: string
nameEn?: string | null
slug: string
_count?: {
projects: number
}
}>
allTags?: Array<{
id: string
name: string
nameEn?: string | null
slug: string
_count?: {
projects: number
}
}>
locale: string
activeTag?: string
}
export function TagCloud({ tags, allTags, locale, activeTag }: TagCloudProps) {
const [searchQuery, setSearchQuery] = useState('')
const [showAll, setShowAll] = useState(false)
// 搜索过滤逻辑
const filteredTags = useMemo(() => {
const sourceTags = allTags || tags
if (!searchQuery.trim()) {
return showAll ? sourceTags : tags
}
const query = searchQuery.toLowerCase()
return sourceTags.filter(tag =>
tag.name.toLowerCase().includes(query) ||
(tag.nameEn && tag.nameEn.toLowerCase().includes(query))
)
}, [searchQuery, showAll, tags, allTags])
const hasMoreTags = allTags && allTags.length > tags.length
const displayName = (tag: typeof tags[0]) =>
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return (
<div className="space-y-4">
{/* 搜索框 */}
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={locale === 'zh' ? '搜索标签...' : 'Search tags...'}
className="w-full px-4 py-2 pl-10 bg-white dark:bg-surface-dark border-2 border-gray-300 dark:border-gray-600 focus:border-primary text-sm font-display focus:outline-none transition-colors"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* 标签列表 */}
<div className="flex flex-wrap gap-2">
{filteredTags.length > 0 ? (
filteredTags.map((tag) => {
const count = tag._count?.projects || 0
const isActive = activeTag === tag.slug
return (
<Link
key={tag.id}
href={`/${locale}/projects?tag=${tag.slug}`}
className={`px-3 py-1 border-2 font-display text-xs font-bold uppercase transition-all shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center gap-2 group ${
isActive
? 'bg-black text-white border-black'
: 'bg-white dark:bg-surface-dark border-black dark:border-gray-500 hover:bg-black hover:text-white dark:hover:bg-primary dark:hover:text-black'
}`}
>
{displayName(tag)}
<span className={`text-[10px] px-1.5 py-0.5 ${
isActive
? 'bg-gray-700 text-white'
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 group-hover:bg-white group-hover:text-black'
}`}>
{count}
</span>
</Link>
)
})
) : (
<div className="text-center py-4 text-gray-500 text-sm w-full">
{locale === 'zh' ? '未找到匹配的标签' : 'No matching tags found'}
</div>
)}
</div>
{/* 显示全部按钮 */}
{!searchQuery && hasMoreTags && !showAll && (
<button
onClick={() => setShowAll(true)}
className="w-full py-2 bg-gray-100 dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 font-display text-xs font-bold uppercase hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{locale === 'zh'
? `显示全部标签 (+${allTags!.length - tags.length})`
: `Show all tags (+${allTags!.length - tags.length})`}
</button>
)}
{/* 收起按钮 */}
{showAll && !searchQuery && (
<button
onClick={() => setShowAll(false)}
className="text-xs font-display font-bold text-gray-500 hover:text-black"
>
{locale === 'zh' ? '↑ 收起' : '↑ Show less'}
</button>
)}
</div>
)
}
-56
View File
@@ -1,56 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
interface SearchBarProps {
locale: string
searchPlaceholder: string
searchLabel: string
}
export function SearchBar({ locale, searchPlaceholder, searchLabel }: SearchBarProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [query, setQuery] = useState(searchParams.get('search') || '')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const params = new URLSearchParams()
if (query) params.set('search', query)
router.push(`/${locale}/projects?${params.toString()}`)
}
return (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
<div className="relative group">
{/* Glow effect on hover */}
<div className="absolute -inset-1 bg-black dark:bg-primary rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-200"></div>
<div className="relative flex items-center">
{/* Search icon */}
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span className="text-gray-400">🔍</span>
</div>
{/* Input */}
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="block w-full pl-12 pr-32 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
/>
{/* Search button */}
<button
type="submit"
className="absolute inset-y-2 right-2 px-4 bg-primary text-black font-bold font-display text-sm border-2 border-black hover:bg-yellow-400 transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
>
{searchLabel}
</button>
</div>
</div>
</form>
)
}
-28
View File
@@ -1,28 +0,0 @@
import { useSearchParams } from 'next/navigation'
export function useSearch() {
const searchParams = useSearchParams()
const tags = (searchParams.get('tags') || '')
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
const domains = (searchParams.get('domains') || '')
.split(',')
.map((domain) => domain.trim())
.filter((domain) => domain.length > 0)
const productForms = (searchParams.get('productForms') || '')
.split(',')
.map((productForm) => productForm.trim())
.filter((productForm) => productForm.length > 0)
return {
search: searchParams.get('search') || '',
tag: searchParams.get('tag') || '',
tags,
domains,
productForms,
projectType: searchParams.get('projectType') || '',
page: Number(searchParams.get('page')) || 1,
}
}
-92
View File
@@ -1,92 +0,0 @@
/**
* GitHub API 服务
* 获取仓库的统计数据(stars, forks, issues, license 等)
*/
export interface GitHubStats {
stargazers_count: number
forks_count: number
open_issues_count: number
license: { key: string; name: string } | null
pushed_at: string
}
/**
* 从 GitHub API 获取仓库统计信息
* @param owner - 仓库所有者
* @param repo - 仓库名称
* @returns GitHub 统计数据或 null
*/
export async function getGitHubStats(
owner: string,
repo: string
): Promise<GitHubStats | null> {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
Accept: 'application/vnd.github.v3+json',
// 如果需要更高的速率限制,可以添加 GitHub token
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
next: { revalidate: 300 } // 缓存 5 分钟
})
if (!response.ok) {
console.error(`GitHub API error: ${response.status}`)
return null
}
const data = await response.json()
return {
stargazers_count: data.stargazers_count || 0,
forks_count: data.forks_count || 0,
open_issues_count: data.open_issues_count || 0,
license: data.license || null,
pushed_at: data.pushed_at || ''
}
} catch (error) {
console.error('Failed to fetch GitHub stats:', error)
return null
}
}
/**
* 格式化数字(如 142000 -> 142k
*/
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
}
return num.toString()
}
/**
* 格式化相对时间(如 "2 days ago"
*/
export function formatRelativeTime(dateString: string, locale: string = 'zh'): string {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (locale === 'en') {
if (diffDays === 0) return 'today'
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays} days ago`
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
return `${Math.floor(diffDays / 365)} years ago`
} else {
if (diffDays === 0) return '今天'
if (diffDays === 1) return '昨天'
if (diffDays < 7) return `${diffDays} 天前`
if (diffDays < 30) return `${Math.floor(diffDays / 7)} 周前`
if (diffDays < 365) return `${Math.floor(diffDays / 30)} 月前`
return `${Math.floor(diffDays / 365)} 年前`
}
}
-6
View File
@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-1
View File
@@ -3,7 +3,6 @@ import type { Config } from "tailwindcss"
const config: Config = { const config: Config = {
darkMode: ["class"], darkMode: ["class"],
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
], ],