feat: 实现 Agent Park AI 项目导航网站核心功能
完成 Next.js 14+ 全栈应用的 MVP 实现,包含以下功能: ## 项目设置 - 初始化 Next.js 14+ 项目,配置 TypeScript 严格模式 - 配置 Tailwind CSS、ESLint、Prettier 代码质量工具 - 集成 shadcn/ui 组件库和 next-intl 国际化方案 ## 数据层 - 配置 PostgreSQL + Prisma ORM - 定义 Project、Tag、ExternalLink 数据模型 - 实现种子数据脚本(5个AI项目) ## 用户故事 1:浏览和搜索 AI 项目 - 实现首页,展示热门标签云和精选项目 - 实现项目列表页,支持搜索和标签筛选 - 创建 TagCloud、ProjectCard、ProjectList、SearchBar 组件 - 实现 ISR 缓存策略优化性能 ## 用户故事 2:查看项目详细信息 - 实现项目详情页,显示完整信息 - 创建 ExternalLinkCard 组件展示外部链接 - 实现安全的 target="_blank" 外链跳转 ## 用户故事 4:数据更新和管理 - 实现 webhook API 端点接收 n8n 数据推送 - 实现 API Key 身份验证 - 支持部分成功模式的批量数据处理 - 完善的错误处理和日志记录 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
|
||||||
|
|
||||||
|
# Site
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Webhook API - Generate a secure key for production
|
||||||
|
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
NEXT_INTL_DEFAULT_LOCALE="zh"
|
||||||
|
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "prettier"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,10 @@ temp/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
/prisma/migrations/*_*/
|
||||||
|
!.prisma/migrations/migration_lock.toml
|
||||||
|
|
||||||
# Redis dump
|
# Redis dump
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const createNextIntlPlugin = require('next-intl/plugin')
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ hostname: 'localhost' },
|
||||||
|
{ hostname: '*.anthropic.com' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withNextIntl(nextConfig)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-park-v2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.1.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"next-intl": "^4.0.2",
|
||||||
|
"@prisma/client": "^6.1.0",
|
||||||
|
"zod": "^3.24.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"typescript": "^5",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.1.6",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"postcss": "^8",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"prisma": "^6.1.0",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["postgresqlExtensions"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Enums
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
enum ProjectStatus {
|
||||||
|
ACTIVE
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LinkType {
|
||||||
|
WEBSITE
|
||||||
|
GITHUB
|
||||||
|
HUGGINGFACE
|
||||||
|
PAPER
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Models
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
nameEn String?
|
||||||
|
slug String @unique
|
||||||
|
description String
|
||||||
|
descriptionEn String?
|
||||||
|
content String? @db.Text
|
||||||
|
contentEn String? @db.Text
|
||||||
|
status ProjectStatus @default(ACTIVE)
|
||||||
|
source String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
tags Tag[]
|
||||||
|
links ExternalLink[]
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([status, createdAt], map: "idx_project_status_createdAt")
|
||||||
|
@@index([slug], map: "idx_project_slug")
|
||||||
|
@@map("projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
nameEn String?
|
||||||
|
slug String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projects Project[]
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([slug], map: "idx_tag_slug")
|
||||||
|
@@map("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExternalLink {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type LinkType
|
||||||
|
url String
|
||||||
|
title String?
|
||||||
|
projectId String
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
@@index([projectId], map: "idx_link_projectId")
|
||||||
|
@@index([type], map: "idx_link_type")
|
||||||
|
@@map("external_links")
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
import { PrismaClient, ProjectStatus, LinkType } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting seed...')
|
||||||
|
|
||||||
|
// 创建标签
|
||||||
|
const tags = await Promise.all([
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: 'code-assistant' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '代码助手',
|
||||||
|
nameEn: 'Code Assistant',
|
||||||
|
slug: 'code-assistant'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: 'image-generation' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '图像生成',
|
||||||
|
nameEn: 'Image Generation',
|
||||||
|
slug: 'image-generation'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: 'data-analysis' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '数据分析',
|
||||||
|
nameEn: 'Data Analysis',
|
||||||
|
slug: 'data-analysis'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: 'chatbot' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '对话AI',
|
||||||
|
nameEn: 'Chatbot',
|
||||||
|
slug: 'chatbot'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: 'automation' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: '自动化',
|
||||||
|
nameEn: 'Automation',
|
||||||
|
slug: 'automation'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`Created ${tags.length} tags`)
|
||||||
|
|
||||||
|
// 创建项目
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
name: 'Claude',
|
||||||
|
nameEn: 'Claude',
|
||||||
|
slug: 'claude',
|
||||||
|
description: 'Anthropic 开发的 AI 助手,擅长分析、写作和编程任务。',
|
||||||
|
descriptionEn: 'AI assistant by Anthropic, excels at analysis, writing, and coding.',
|
||||||
|
content: 'Claude 是由 Anthropic 开发的下一代 AI 助手。它基于 Constitutional AI 方法训练,强调安全性、诚实性和有用性。',
|
||||||
|
contentEn: 'Claude is a next-generation AI assistant developed by Anthropic. Trained using Constitutional AI methods.',
|
||||||
|
tagSlugs: ['code-assistant', 'chatbot'],
|
||||||
|
links: [
|
||||||
|
{ type: LinkType.WEBSITE, url: 'https://www.anthropic.com/claude', title: 'Official Website' },
|
||||||
|
{ type: LinkType.GITHUB, url: 'https://github.com/anthropics', title: 'GitHub' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatGPT',
|
||||||
|
nameEn: 'ChatGPT',
|
||||||
|
slug: 'chatgpt',
|
||||||
|
description: 'OpenAI 开发的大型语言模型,支持对话、写作、编程等多种任务。',
|
||||||
|
descriptionEn: 'Large language model by OpenAI, supports conversation, writing, coding, etc.',
|
||||||
|
tagSlugs: ['chatbot', 'code-assistant'],
|
||||||
|
links: [
|
||||||
|
{ type: LinkType.WEBSITE, url: 'https://chat.openai.com', title: 'Official Website' },
|
||||||
|
{ type: LinkType.GITHUB, url: 'https://github.com/openai', title: 'GitHub' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Midjourney',
|
||||||
|
nameEn: 'Midjourney',
|
||||||
|
slug: 'midjourney',
|
||||||
|
description: '强大的 AI 图像生成工具,通过文字描述创建精美图像。',
|
||||||
|
descriptionEn: 'Powerful AI image generation tool that creates stunning images from text descriptions.',
|
||||||
|
tagSlugs: ['image-generation'],
|
||||||
|
links: [
|
||||||
|
{ type: LinkType.WEBSITE, url: 'https://www.midjourney.com', title: 'Official Website' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Stable Diffusion',
|
||||||
|
nameEn: 'Stable Diffusion',
|
||||||
|
slug: 'stable-diffusion',
|
||||||
|
description: '开源的图像生成模型,支持本地部署和自定义训练。',
|
||||||
|
descriptionEn: 'Open-source image generation model, supports local deployment and custom training.',
|
||||||
|
tagSlugs: ['image-generation'],
|
||||||
|
links: [
|
||||||
|
{ type: LinkType.WEBSITE, url: 'https://stability.ai', title: 'Official Website' },
|
||||||
|
{ type: LinkType.GITHUB, url: 'https://github.com/Stability-AI', title: 'GitHub' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n',
|
||||||
|
nameEn: 'n8n',
|
||||||
|
slug: 'n8n',
|
||||||
|
description: '开源的工作流自动化工具,通过可视化界面连接各种服务和 API。',
|
||||||
|
descriptionEn: 'Open-source workflow automation tool that connects various services and APIs via visual interface.',
|
||||||
|
tagSlugs: ['automation'],
|
||||||
|
links: [
|
||||||
|
{ type: LinkType.WEBSITE, url: 'https://n8n.io', title: 'Official Website' },
|
||||||
|
{ type: LinkType.GITHUB, url: 'https://github.com/n8n-io/n8n', title: 'GitHub' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const projectData of projects) {
|
||||||
|
const { tagSlugs, links, ...rest } = projectData
|
||||||
|
const projectTags = tags.filter(t => tagSlugs.includes(t.slug))
|
||||||
|
|
||||||
|
await prisma.project.upsert({
|
||||||
|
where: { slug: rest.slug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...rest,
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
source: 'manual',
|
||||||
|
tags: {
|
||||||
|
connect: projectTags.map(t => ({ id: t.id }))
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
create: links
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created ${projects.length} projects`)
|
||||||
|
console.log('Seed completed successfully!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Error seeding database:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { getRequestConfig } from 'next-intl/server'
|
||||||
|
import { setRequestLocale } from 'next-intl/server'
|
||||||
|
|
||||||
|
const locales = ['zh', 'en']
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({ locale }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}) {
|
||||||
|
const { locale } = await params
|
||||||
|
if (!locales.includes(locale)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<nav className="flex items-center justify-between">
|
||||||
|
<a href={`/${locale}`} className="text-xl font-semibold">
|
||||||
|
Agent Park
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href={`/${locale}`} className="text-sm hover:underline">
|
||||||
|
首页
|
||||||
|
</a>
|
||||||
|
<a href={`/${locale}/projects`} className="text-sm hover:underline">
|
||||||
|
项目列表
|
||||||
|
</a>
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<a href="/zh" className={locale === 'zh' ? 'font-semibold' : 'hover:underline'}>
|
||||||
|
中文
|
||||||
|
</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/en" className={locale === 'en' ? 'font-semibold' : 'hover:underline'}>
|
||||||
|
EN
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<footer className="border-t py-6">
|
||||||
|
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
|
||||||
|
© 2025 Agent Park. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { getProjects, getAllTags } from '@/hooks/useProjects'
|
||||||
|
import { ProjectList } from '@/components/project/ProjectList'
|
||||||
|
import { TagCloud } from '@/components/project/TagCloud'
|
||||||
|
import { SearchBar } from '@/components/search/SearchBar'
|
||||||
|
|
||||||
|
interface HomePageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage({ params }: HomePageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
const t = await getTranslations('home')
|
||||||
|
|
||||||
|
const [projectsData, tags] = await Promise.all([
|
||||||
|
getProjects({ limit: 6 }),
|
||||||
|
getAllTags(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">{t('title')}</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBar locale={locale} />
|
||||||
|
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">{t('browseByTag')}</h2>
|
||||||
|
<TagCloud tags={tags} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">{t('featuredProjects')}</h2>
|
||||||
|
<ProjectList projects={projectsData.projects} locale={locale} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 300 // ISR: revalidate every 5 minutes
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getProjectBySlug } from '@/hooks/useProjects'
|
||||||
|
import { ExternalLinkCard } from '@/components/project/ExternalLinkCard'
|
||||||
|
|
||||||
|
interface ProjectDetailPageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectDetailPage({
|
||||||
|
params,
|
||||||
|
}: ProjectDetailPageProps) {
|
||||||
|
const { locale, id } = await params
|
||||||
|
const project = await getProjectBySlug(id)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<a
|
||||||
|
href={`/${locale}/projects`}
|
||||||
|
className="text-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
← 返回项目列表
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">{project.name}</h1>
|
||||||
|
{project.nameEn && (
|
||||||
|
<h2 className="text-xl text-muted-foreground mb-4">{project.nameEn}</h2>
|
||||||
|
)}
|
||||||
|
<p className="text-lg text-muted-foreground">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.content && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">详细介绍</h3>
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<p>{project.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">标签</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-secondary text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLinkCard links={project.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 300 // ISR: revalidate every 5 minutes
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { getProjects, getAllTags } from '@/hooks/useProjects'
|
||||||
|
import { ProjectList } from '@/components/project/ProjectList'
|
||||||
|
import { TagCloud } from '@/components/project/TagCloud'
|
||||||
|
import { SearchBar } from '@/components/search/SearchBar'
|
||||||
|
|
||||||
|
interface ProjectsPageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
searchParams: Promise<{ search?: string; tag?: string; page?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: ProjectsPageProps) {
|
||||||
|
const t = await getTranslations('home')
|
||||||
|
|
||||||
|
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
|
||||||
|
const { locale } = resolvedParams
|
||||||
|
const search = resolvedSearchParams.search || ''
|
||||||
|
const tag = resolvedSearchParams.tag || ''
|
||||||
|
const page = Number(resolvedSearchParams.page) || 1
|
||||||
|
|
||||||
|
const [projectsData, tags] = await Promise.all([
|
||||||
|
getProjects({ search, tag, page }),
|
||||||
|
getAllTags(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">项目列表</h1>
|
||||||
|
<SearchBar locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">{t('browseByTag')}</h2>
|
||||||
|
<TagCloud tags={tags} locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ProjectList projects={projectsData.projects} locale={locale} />
|
||||||
|
|
||||||
|
{projectsData.pagination.totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-8">
|
||||||
|
{page > 1 && (
|
||||||
|
<a
|
||||||
|
href={`?search=${search}&tag=${tag}&page=${page - 1}`}
|
||||||
|
className="px-4 py-2 border rounded-lg hover:bg-secondary"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="px-4 py-2">
|
||||||
|
第 {page} / {projectsData.pagination.totalPages} 页
|
||||||
|
</span>
|
||||||
|
{page < projectsData.pagination.totalPages && (
|
||||||
|
<a
|
||||||
|
href={`?search=${search}&tag=${tag}&page=${page + 1}`}
|
||||||
|
className="px-4 py-2 border rounded-lg hover:bg-secondary"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 300 // ISR: revalidate every 5 minutes
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
WebhookPayloadSchema,
|
||||||
|
ProjectInputSchema,
|
||||||
|
type WebhookPayload,
|
||||||
|
type ProjectInput,
|
||||||
|
} from '@/lib/validations'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Validate payload
|
||||||
|
const validationResult = WebhookPayloadSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Validation error',
|
||||||
|
details: validationResult.error.errors.map((e) => e.message),
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = validationResult.data as WebhookPayload
|
||||||
|
|
||||||
|
// Verify API Key
|
||||||
|
const apiKey = process.env.WEBHOOK_API_KEY
|
||||||
|
if (payload.apiKey !== apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
details: ['Invalid or missing API Key'],
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process projects with partial success mode
|
||||||
|
const results = {
|
||||||
|
processed: payload.projects.length,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as Array<{
|
||||||
|
index: number
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
value: any
|
||||||
|
}>,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < payload.projects.length; i++) {
|
||||||
|
const projectData = payload.projects[i]
|
||||||
|
|
||||||
|
// Validate individual project
|
||||||
|
const projectValidation = ProjectInputSchema.safeParse(projectData)
|
||||||
|
|
||||||
|
if (!projectValidation.success) {
|
||||||
|
results.failed++
|
||||||
|
results.errors.push({
|
||||||
|
index: i,
|
||||||
|
field: projectValidation.error.errors[0].path.join('.'),
|
||||||
|
message: projectValidation.error.errors[0].message,
|
||||||
|
value: projectData,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validProject = projectValidation.data as ProjectInput
|
||||||
|
|
||||||
|
// Upsert tags
|
||||||
|
const tagConnections = await Promise.all(
|
||||||
|
validProject.tags.map(async (tag) => {
|
||||||
|
const slug =
|
||||||
|
tag.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
|
||||||
|
tag.name.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
|
||||||
|
return prisma.tag.upsert({
|
||||||
|
where: { slug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: tag.name,
|
||||||
|
nameEn: tag.nameEn || null,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate slug for project
|
||||||
|
const slug =
|
||||||
|
validProject.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
|
||||||
|
validProject.name.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
|
||||||
|
// Upsert project
|
||||||
|
const existingProject = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingProject) {
|
||||||
|
// Update existing project
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: existingProject.id },
|
||||||
|
data: {
|
||||||
|
name: validProject.name,
|
||||||
|
nameEn: validProject.nameEn || null,
|
||||||
|
description: validProject.description,
|
||||||
|
descriptionEn: validProject.descriptionEn || null,
|
||||||
|
content: validProject.content || null,
|
||||||
|
contentEn: validProject.contentEn || null,
|
||||||
|
status: validProject.status as any,
|
||||||
|
source: validProject.source || null,
|
||||||
|
tags: {
|
||||||
|
set: tagConnections.map((t) => ({ id: t.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update links (delete old ones, create new ones)
|
||||||
|
await prisma.externalLink.deleteMany({
|
||||||
|
where: { projectId: existingProject.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.externalLink.createMany({
|
||||||
|
data: validProject.links.map((link) => ({
|
||||||
|
type: link.type as any,
|
||||||
|
url: link.url,
|
||||||
|
title: link.title || null,
|
||||||
|
projectId: existingProject.id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
results.updated++
|
||||||
|
} else {
|
||||||
|
// Create new project
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: validProject.name,
|
||||||
|
nameEn: validProject.nameEn || null,
|
||||||
|
slug,
|
||||||
|
description: validProject.description,
|
||||||
|
descriptionEn: validProject.descriptionEn || null,
|
||||||
|
content: validProject.content || null,
|
||||||
|
contentEn: validProject.contentEn || null,
|
||||||
|
status: validProject.status as any,
|
||||||
|
source: validProject.source || null,
|
||||||
|
tags: {
|
||||||
|
connect: tagConnections.map((t) => ({ id: t.id })),
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
create: validProject.links.map((link) => ({
|
||||||
|
type: link.type as any,
|
||||||
|
url: link.url,
|
||||||
|
title: link.title || null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
results.created++
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++
|
||||||
|
results.errors.push({
|
||||||
|
index: i,
|
||||||
|
field: 'general',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
value: projectData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
console.log(
|
||||||
|
`[Webhook] Processed ${results.processed} projects in ${duration}ms: ${results.created} created, ${results.updated} updated, ${results.failed} failed`
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
...results,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Webhook] Error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import { Inter } from "next/font/google"
|
||||||
|
import "./globals.css"
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Agent Park - AI 项目导航",
|
||||||
|
description: "发现和探索全网优质 AI 项目",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ExternalLink } from '@prisma/client'
|
||||||
|
|
||||||
|
interface ExternalLinkCardProps {
|
||||||
|
links: ExternalLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkTypeNames = {
|
||||||
|
WEBSITE: '官网',
|
||||||
|
GITHUB: 'GitHub',
|
||||||
|
HUGGINGFACE: 'HuggingFace',
|
||||||
|
PAPER: '论文',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalLinkCard({ links }: ExternalLinkCardProps) {
|
||||||
|
if (links.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">外部链接</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{links.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.title || linkTypeNames[link.type]}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{link.url}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-primary">→</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
descriptionEn?: string | null
|
||||||
|
tags: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ project, locale }: ProjectCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{project.name}</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 line-clamp-2">{project.description}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded text-xs bg-secondary text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/projects/${project.slug}`}
|
||||||
|
className="text-primary hover:underline text-sm font-medium"
|
||||||
|
>
|
||||||
|
查看详情 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ProjectCard } from './ProjectCard'
|
||||||
|
|
||||||
|
interface ProjectListProps {
|
||||||
|
projects: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
descriptionEn?: string | null
|
||||||
|
tags: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectList({ projects, locale }: ProjectListProps) {
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">暂无项目</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface TagCloudProps {
|
||||||
|
tags: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
_count?: {
|
||||||
|
projects: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagCloud({ tags, locale }: TagCloudProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||||
|
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{tag._count && tag._count.projects > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{tag._count.projects}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ locale }: 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="w-full max-w-md mx-auto mb-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索 AI 项目..."
|
||||||
|
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function getProjects(options?: {
|
||||||
|
search?: string
|
||||||
|
tag?: string
|
||||||
|
status?: 'ACTIVE' | 'ARCHIVED'
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
tag,
|
||||||
|
status = 'ACTIVE',
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
} = options || {}
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ descriptionEn: { contains: search, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
where.tags = {
|
||||||
|
some: {
|
||||||
|
slug: tag,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
tags: true,
|
||||||
|
links: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectBySlug(slug: string) {
|
||||||
|
return prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
tags: true,
|
||||||
|
links: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllTags() {
|
||||||
|
return prisma.tag.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { projects: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagsWithProjectCounts() {
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { projects: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return tags.filter(tag => tag._count.projects > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
|
export function useSearch() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
return {
|
||||||
|
search: searchParams.get('search') || '',
|
||||||
|
tag: searchParams.get('tag') || '',
|
||||||
|
page: Number(searchParams.get('page')) || 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server'
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
// This typically corresponds to the `[locale]` segment
|
||||||
|
let locale = await requestLocale
|
||||||
|
|
||||||
|
// Ensure that a valid locale is used
|
||||||
|
if (!locale || !['zh', 'en'].includes(locale)) {
|
||||||
|
locale = 'zh'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Enums
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export const ProjectStatusEnum = z.enum(['ACTIVE', 'ARCHIVED'])
|
||||||
|
export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER'])
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Base Schemas
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export const ExternalLinkSchema = z.object({
|
||||||
|
type: LinkTypeEnum,
|
||||||
|
url: z.string().url('Invalid URL format'),
|
||||||
|
title: z.string().max(200).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TagSchema = z.object({
|
||||||
|
name: z.string().min(1).max(50),
|
||||||
|
nameEn: z.string().max(50).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Project Schemas
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export const ProjectBaseSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
nameEn: z.string().max(200).optional(),
|
||||||
|
description: z.string().min(10).max(500),
|
||||||
|
descriptionEn: z.string().max(500).optional(),
|
||||||
|
content: z.string().max(10000).optional(),
|
||||||
|
contentEn: z.string().max(10000).optional(),
|
||||||
|
status: ProjectStatusEnum.default('ACTIVE'),
|
||||||
|
source: z.string().max(100).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ProjectInputSchema = ProjectBaseSchema.extend({
|
||||||
|
tags: z.array(TagSchema).min(1, 'At least one tag is required').max(10),
|
||||||
|
links: z.array(ExternalLinkSchema).min(1, 'At least one link is required').max(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Webhook Schemas
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export const WebhookAuthSchema = z.object({
|
||||||
|
apiKey: z.string().min(32, 'Invalid API key format')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const WebhookPayloadSchema = WebhookAuthSchema.extend({
|
||||||
|
projects: z.array(ProjectInputSchema).min(1).max(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Query Schemas
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export const ProjectQuerySchema = z.object({
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
status: ProjectStatusEnum.optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Types
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export type ExternalLink = z.infer<typeof ExternalLinkSchema>
|
||||||
|
export type Tag = z.infer<typeof TagSchema>
|
||||||
|
export type ProjectInput = z.infer<typeof ProjectInputSchema>
|
||||||
|
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>
|
||||||
|
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"search": "Search",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"viewMore": "View More",
|
||||||
|
"backToHome": "Back to Home"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "AI Project Navigator",
|
||||||
|
"subtitle": "Discover and explore AI projects",
|
||||||
|
"featuredProjects": "Featured Projects",
|
||||||
|
"browseByTag": "Browse by Tag",
|
||||||
|
"searchPlaceholder": "Search AI projects..."
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"details": "Project Details",
|
||||||
|
"externalLinks": "External Links",
|
||||||
|
"viewProject": "View Project",
|
||||||
|
"tags": "Tags",
|
||||||
|
"website": "Website",
|
||||||
|
"github": "GitHub",
|
||||||
|
"huggingface": "HuggingFace",
|
||||||
|
"paper": "Paper"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"projects": "Projects",
|
||||||
|
"about": "About"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"search": "搜索",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noResults": "未找到结果",
|
||||||
|
"viewMore": "查看更多",
|
||||||
|
"backToHome": "返回首页"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "AI 项目导航",
|
||||||
|
"subtitle": "发现和探索全网优质 AI 项目",
|
||||||
|
"featuredProjects": "精选项目",
|
||||||
|
"browseByTag": "按标签浏览",
|
||||||
|
"searchPlaceholder": "搜索 AI 项目..."
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"details": "项目详情",
|
||||||
|
"externalLinks": "外部链接",
|
||||||
|
"viewProject": "查看项目",
|
||||||
|
"tags": "标签",
|
||||||
|
"website": "官网",
|
||||||
|
"github": "GitHub",
|
||||||
|
"huggingface": "HuggingFace",
|
||||||
|
"paper": "论文"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "首页",
|
||||||
|
"projects": "项目列表",
|
||||||
|
"about": "关于"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware'
|
||||||
|
|
||||||
|
export default createMiddleware({
|
||||||
|
locales: ['zh', 'en'],
|
||||||
|
defaultLocale: 'zh',
|
||||||
|
localePrefix: 'always'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user