chore: remove unused code and tooling
This commit is contained in:
@@ -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
@@ -7,41 +7,26 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/speed-insights": "^1.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.11",
|
||||
"next-intl": "^4.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-shiki": "^0.0.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.20.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.11",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+17
-1644
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,10 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
params: _params
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations('home')
|
||||
|
||||
return {
|
||||
@@ -43,7 +42,6 @@ export default async function LocaleLayout({
|
||||
// Get translations
|
||||
const t = await getTranslations('layout')
|
||||
const tNav = await getTranslations('navigation')
|
||||
const tHome = await getTranslations('home')
|
||||
const messages = await getMessages()
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma";
|
||||
*
|
||||
* 根据项目的 slug 获取项目详情
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
|
||||
@@ -2,8 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
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 tx = {
|
||||
const tx: MaintenanceRouteTxMock = {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
@@ -18,7 +32,7 @@ const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
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(),
|
||||
txMock: tx,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
import { executeTagMaintenance } from "./service";
|
||||
|
||||
function createTxMock() {
|
||||
return {
|
||||
@@ -34,7 +34,7 @@ describe("executeTagMaintenance", () => {
|
||||
tx.projectTag.createMany.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: [],
|
||||
merges: [
|
||||
{
|
||||
@@ -70,11 +70,11 @@ describe("executeTagMaintenance", () => {
|
||||
tx.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
).rejects.toMatchObject<TagMaintenanceApiError>({
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: "Validation error",
|
||||
});
|
||||
|
||||
@@ -2,9 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
type ResetProjectsRouteTxMock = {
|
||||
projectTag: {
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
|
||||
vi.hoisted(() => {
|
||||
const tx = {
|
||||
const tx: ResetProjectsRouteTxMock = {
|
||||
projectTag: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
@@ -13,7 +20,7 @@ const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFin
|
||||
|
||||
return {
|
||||
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,
|
||||
projectFindManyMock: 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 () => {
|
||||
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 json = await response.json();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
const locales = ['zh', 'en'] as const
|
||||
const localeNames: Record<string, string> = {
|
||||
zh: '中文',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -116,9 +116,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
{/* Full content with Markdown rendering */}
|
||||
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
|
||||
</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) {
|
||||
const t = await getTranslations('project')
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
|
||||
// 获取 GitHub 统计数据
|
||||
const githubInfo = getGitHubInfoFromLinks(project.links)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)} 年前`
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { Config } from "tailwindcss"
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user