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",
|
"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",
|
||||||
|
|||||||
@@ -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({
|
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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 */}
|
{/* 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)
|
||||||
|
|||||||
@@ -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 = {
|
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}",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user