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
|
||||
*.sqlite3
|
||||
|
||||
# Prisma
|
||||
/prisma/migrations/*_*/
|
||||
!.prisma/migrations/migration_lock.toml
|
||||
|
||||
# Redis dump
|
||||
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