feat: 优化项目网站 UI/UX 并新增项目详情组件
- 重构首页和项目列表页布局,优化视觉层次 - 新增 ProjectDetail、ProjectSidebar、RelatedProjects 组件 - 改进项目卡片样式,增加悬停效果 - 优化标签云和搜索栏交互体验 - 更新全局样式和 Tailwind 配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+152
-28
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import Link from "next/link"
|
||||
|
||||
const locales = ['zh', 'en']
|
||||
|
||||
@@ -24,36 +24,160 @@ export default async function LocaleLayout({
|
||||
|
||||
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>
|
||||
{/* Top announcement bar */}
|
||||
<div className="bg-primary w-full py-2 px-4 border-b-2 border-black dark:border-gray-600 flex justify-center items-center text-xs font-bold font-display tracking-wide text-black">
|
||||
<span>DISCOVER THE FUTURE OF AI AGENTS</span>
|
||||
<span className="material-icons text-sm align-middle ml-1">arrow_forward</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<span className="material-icons text-3xl">smart_toy</span>
|
||||
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight">
|
||||
Agent Park
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex space-x-8 items-center">
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}`}
|
||||
>
|
||||
HOME
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/projects`}
|
||||
>
|
||||
PROJECTS
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href="#"
|
||||
>
|
||||
BLOG
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href="#"
|
||||
>
|
||||
ABOUT
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<button className="font-display text-sm font-bold hover:opacity-70">
|
||||
中文 / EN
|
||||
</button>
|
||||
<Link
|
||||
className="bg-white dark:bg-surface-dark border-2 border-black dark:border-white px-4 py-2 font-display text-sm font-bold shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
href="#"
|
||||
>
|
||||
SUBMIT PROJECT
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<button className="text-text-light dark:text-text-dark hover:text-gray-600 focus:outline-none">
|
||||
<span className="material-icons">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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.
|
||||
|
||||
<main className="flex-1 relative">{children}</main>
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<section className="border-t-2 border-black dark:border-gray-600">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2">
|
||||
<div className="bg-primary dark:bg-yellow-600 p-12 md:p-20 border-b-2 md:border-b-0 md:border-r-2 border-black dark:border-gray-600 flex flex-col justify-center">
|
||||
<h2 className="font-display text-4xl font-bold text-black mb-4">STAY UPDATED</h2>
|
||||
<p className="font-sans text-black mb-8 max-w-md">
|
||||
Get the latest AI tools and trends delivered straight to your inbox. No spam, just intelligence.
|
||||
</p>
|
||||
<form className="space-y-4 max-w-md">
|
||||
<input
|
||||
className="w-full bg-background-light border-2 border-black p-3 font-display text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
placeholder="E-mail"
|
||||
type="email"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="w-4 h-4 border-2 border-black text-black focus:ring-0" id="consent" type="checkbox" />
|
||||
<label className="text-xs font-bold font-display text-black" htmlFor="consent">
|
||||
Subscribe to Agent Park updates
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-white text-black font-display font-bold py-3 px-8 border-2 border-black shadow-neo hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
type="submit"
|
||||
>
|
||||
SUBSCRIBE
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="bg-background-light dark:bg-background-dark p-12 md:p-20 relative overflow-hidden flex items-center justify-center">
|
||||
<div className="relative w-64 h-64">
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-white dark:bg-surface-dark transform rotate-3"></div>
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-primary dark:bg-primary transform -rotate-3 translate-x-4 translate-y-4 opacity-80"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<span className="material-icons text-8xl">rocket_launch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-background-light dark:bg-background-dark border-t-2 border-black dark:border-gray-700 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12">
|
||||
<div className="col-span-1 md:col-span-1">
|
||||
<Link className="flex items-center gap-2 mb-4" href={`/${locale}`}>
|
||||
<span className="material-icons text-2xl">smart_toy</span>
|
||||
<span className="font-display font-bold text-lg tracking-tight">Agent Park</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">Resources</h4>
|
||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Blog</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Documentation</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">Legal</h4>
|
||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Privacy Policy</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">Terms of Service</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">Follow Us</h4>
|
||||
<div className="flex space-x-4">
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">X</Link>
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">Li</Link>
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">Gh</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-black dark:border-gray-700 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-sm text-gray-500 font-display">© 2025 Agent Park. All rights reserved.</p>
|
||||
<div className="mt-4 md:mt-0 font-display text-sm font-bold">
|
||||
DESIGNED FOR AI BUILDERS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
+42
-15
@@ -3,6 +3,7 @@ import { getProjects, getAllTags } from '@/hooks/useProjects'
|
||||
import { ProjectList } from '@/components/project/ProjectList'
|
||||
import { TagCloud } from '@/components/project/TagCloud'
|
||||
import { SearchBar } from '@/components/search/SearchBar'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
@@ -18,24 +19,50 @@ export default async function HomePage({ params }: HomePageProps) {
|
||||
])
|
||||
|
||||
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>
|
||||
<>
|
||||
{/* Hero Section with floating shapes */}
|
||||
<main className="relative overflow-hidden">
|
||||
<div className="absolute top-20 left-10 w-24 h-24 border-2 border-black dark:border-gray-600 rounded-full opacity-20 animate-float hidden lg:block"></div>
|
||||
<div className="absolute top-40 right-20 w-32 h-20 border-2 border-black dark:border-gray-600 rounded-[2rem] transform -rotate-12 opacity-20 animate-float hidden lg:block" style={{ animationDelay: '1s' }}></div>
|
||||
|
||||
<SearchBar locale={locale} />
|
||||
<section className="max-w-4xl mx-auto px-4 py-20 text-center relative z-10">
|
||||
<h1 className="font-display text-5xl md:text-7xl font-bold mb-6 tracking-tight leading-tight">
|
||||
AI PROJECT <br className="hidden md:block" />NAVIGATOR
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-10 max-w-2xl mx-auto font-sans">
|
||||
Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.
|
||||
</p>
|
||||
<Suspense fallback={<div className="h-16"></div>}>
|
||||
<SearchBar locale={locale} />
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('browseByTag')}</h2>
|
||||
<TagCloud tags={tags} locale={locale} />
|
||||
</div>
|
||||
{/* Browse by Tags Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-16">
|
||||
<h2 className="font-display text-xl font-bold mb-6 border-b-2 border-black dark:border-gray-700 pb-2 inline-block">
|
||||
BROWSE BY TAGS
|
||||
</h2>
|
||||
<TagCloud tags={tags} locale={locale} />
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">{t('featuredProjects')}</h2>
|
||||
<ProjectList projects={projectsData.projects} locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Featured Projects Section */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<h2 className="font-display text-3xl font-bold">FEATURED PROJECTS</h2>
|
||||
<a className="hidden md:flex items-center font-display text-sm font-bold hover:underline" href={`/${locale}/projects`}>
|
||||
VIEW ALL <span className="ml-1">→</span>
|
||||
</a>
|
||||
</div>
|
||||
<ProjectList projects={projectsData.projects} locale={locale} featured />
|
||||
|
||||
<div className="mt-8 text-center md:hidden">
|
||||
<a className="inline-flex items-center font-display text-sm font-bold border-b-2 border-black dark:border-white pb-1" href={`/${locale}/projects`}>
|
||||
VIEW ALL PROJECTS <span className="ml-1">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getProjectBySlug } from '@/hooks/useProjects'
|
||||
import { ExternalLinkCard } from '@/components/project/ExternalLinkCard'
|
||||
import { getProjectBySlug, getProjects } from '@/hooks/useProjects'
|
||||
import { ProjectDetail } from '@/components/project/ProjectDetail'
|
||||
import { ProjectSidebar } from '@/components/project/ProjectSidebar'
|
||||
import { RelatedProjects } from '@/components/project/RelatedProjects'
|
||||
|
||||
interface ProjectDetailPageProps {
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
@@ -9,60 +11,82 @@ interface ProjectDetailPageProps {
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: ProjectDetailPageProps) {
|
||||
const { locale, id } = await params
|
||||
const resolvedParams = await params
|
||||
const { locale, id } = resolvedParams
|
||||
|
||||
// Get project by slug
|
||||
const project = await getProjectBySlug(id)
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Get related projects (same tags, excluding current project)
|
||||
const relatedProjectsResult = await getProjects({
|
||||
limit: 3,
|
||||
})
|
||||
|
||||
const relatedProjects = relatedProjectsResult.projects
|
||||
.filter(p => p.id !== project.id)
|
||||
.filter(p => p.tags.some(t => project.tags.some(pt => pt.id === t.id)))
|
||||
.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<main className="min-h-screen bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark font-body transition-colors duration-200">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Back Button */}
|
||||
<div className="mb-8">
|
||||
<a
|
||||
className="inline-flex items-center text-sm font-display font-bold text-gray-500 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors group"
|
||||
href={`/${locale}/projects`}
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
← 返回项目列表
|
||||
<span className="material-icons text-base mr-1 group-hover:-translate-x-1 transition-transform">
|
||||
arrow_back
|
||||
</span>
|
||||
BACK TO PROJECTS
|
||||
</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>
|
||||
{/* Main Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
{/* Main Content (8 columns) */}
|
||||
<div className="lg:col-span-8">
|
||||
<ProjectDetail project={project} locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar (4 columns) */}
|
||||
<aside className="lg:col-span-4">
|
||||
<ProjectSidebar project={project} locale={locale} />
|
||||
</aside>
|
||||
</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} />
|
||||
{/* Related Projects Section */}
|
||||
<RelatedProjects projects={relatedProjects} locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProjectDetailPageProps) {
|
||||
const resolvedParams = await params
|
||||
const { id, locale } = resolvedParams
|
||||
|
||||
const project = await getProjectBySlug(id)
|
||||
|
||||
if (!project) {
|
||||
return {
|
||||
title: 'Project Not Found',
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
const displayDescription =
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
|
||||
return {
|
||||
title: `${displayName} - Agent Park`,
|
||||
description: displayDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export const revalidate = 300 // ISR: revalidate every 5 minutes
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { getProjects, getAllTags } from '@/hooks/useProjects'
|
||||
import { ProjectList } from '@/components/project/ProjectList'
|
||||
@@ -14,7 +15,7 @@ export default async function ProjectsPage({
|
||||
searchParams,
|
||||
}: ProjectsPageProps) {
|
||||
const t = await getTranslations('home')
|
||||
|
||||
|
||||
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
|
||||
const { locale } = resolvedParams
|
||||
const search = resolvedSearchParams.search || ''
|
||||
@@ -27,45 +28,89 @@ export default async function ProjectsPage({
|
||||
])
|
||||
|
||||
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} />
|
||||
<main className="container mx-auto px-4 py-12 md:py-16 max-w-7xl relative">
|
||||
{/* Decorative shapes */}
|
||||
<div className="absolute top-10 left-0 w-24 h-24 border-2 border-black dark:border-white/20 rounded-full opacity-20 pointer-events-none hidden lg:block"></div>
|
||||
<div className="absolute top-20 right-10 w-32 h-20 border-2 border-black dark:border-white/20 rounded-[2rem] opacity-20 pointer-events-none transform rotate-12 hidden lg:block"></div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="text-center mb-16 relative z-10">
|
||||
<h1 className="font-display text-5xl md:text-7xl font-bold mb-6 leading-tight tracking-tighter">
|
||||
PROJECT <br className="md:hidden" />NAVIGATOR
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto font-light leading-relaxed">
|
||||
Discover and explore high-quality AI projects from across the web. <br className="hidden md:inline" />
|
||||
Curated for developers and enthusiasts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">{t('browseByTag')}</h2>
|
||||
<TagCloud tags={tags} locale={locale} />
|
||||
{/* Search and Filter Section */}
|
||||
<div className="bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-white/20 p-6 md:p-8 mb-12 shadow-neo dark:shadow-none">
|
||||
<Suspense fallback={<div className="h-20"></div>}>
|
||||
<SearchBar locale={locale} />
|
||||
</Suspense>
|
||||
|
||||
<div className="border-t-2 border-gray-100 dark:border-gray-800 pt-6 mt-8">
|
||||
<h3 className="font-display font-bold uppercase text-sm mb-4 border-b-2 border-black inline-block dark:border-primary pb-1">
|
||||
Browse by Tags
|
||||
</h3>
|
||||
<TagCloud tags={tags} locale={locale} activeTag={tag} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProjectList projects={projectsData.projects} locale={locale} />
|
||||
|
||||
{projectsData.pagination.totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
{/* Projects Section */}
|
||||
<div className="flex justify-between items-end mb-8 border-b border-black dark:border-white/20 pb-4">
|
||||
<h2 className="font-display font-bold text-3xl uppercase tracking-tight">
|
||||
{tag ? `Tag: ${tag}` : 'All Projects'}
|
||||
</h2>
|
||||
<span className="font-display text-xs font-bold uppercase text-gray-500">
|
||||
{projectsData.pagination.total} projects
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProjectList projects={projectsData.projects} locale={locale} />
|
||||
|
||||
{/* Pagination */}
|
||||
{projectsData.pagination.totalPages > 1 && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<div className="flex border-2 border-black dark:border-white/20 bg-white dark:bg-surface-dark shadow-neo dark:shadow-none">
|
||||
{page > 1 && (
|
||||
<a
|
||||
href={`?search=${search}&tag=${tag}&page=${page - 1}`}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-secondary"
|
||||
className="px-4 py-2 border-r border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
上一页
|
||||
←
|
||||
</a>
|
||||
)}
|
||||
<span className="px-4 py-2">
|
||||
第 {page} / {projectsData.pagination.totalPages} 页
|
||||
</span>
|
||||
{Array.from({ length: Math.min(5, projectsData.pagination.totalPages) }, (_, i) => {
|
||||
const pageNum = Math.max(1, page - 2) + i
|
||||
if (pageNum > projectsData.pagination.totalPages) return null
|
||||
return (
|
||||
<a
|
||||
key={pageNum}
|
||||
href={`?search=${search}&tag=${tag}&page=${pageNum}`}
|
||||
className={`px-4 py-2 font-display font-bold text-sm border-r border-black dark:border-white/20 ${
|
||||
pageNum === page
|
||||
? 'bg-primary text-black'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-white/10'
|
||||
} transition-colors`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
{page < projectsData.pagination.totalPages && (
|
||||
<a
|
||||
href={`?search=${search}&tag=${tag}&page=${page + 1}`}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-secondary"
|
||||
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
下一页
|
||||
→
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!projectValidation.success) {
|
||||
results.failed++
|
||||
const firstError = projectValidation.error.errors[0]
|
||||
results.errors.push({
|
||||
index: i,
|
||||
field: projectValidation.error.errors[0].path.join('.'),
|
||||
message: projectValidation.error.errors[0].message,
|
||||
field: firstError?.path.join('.') || 'unknown',
|
||||
message: firstError?.message || 'Validation failed',
|
||||
value: projectData,
|
||||
})
|
||||
continue
|
||||
|
||||
+83
-36
@@ -1,59 +1,106 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
|
||||
|
||||
@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%;
|
||||
/* Neo-brutalism theme colors - Light mode */
|
||||
--background: 40 20% 95%; /* #F5F2EB */
|
||||
--foreground: 0 0% 0%; /* #000000 */
|
||||
--card: 0 0% 100%; /* #FFFFFF */
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 0%;
|
||||
--primary: 48 100% 50%; /* #FFD700 */
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--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;
|
||||
--border: 0 0% 0%; /* #000000 */
|
||||
--input: 0 0% 0%;
|
||||
--ring: 0 0% 0%;
|
||||
--radius: 0rem; /* Sharp corners for neo-brutalism */
|
||||
}
|
||||
|
||||
.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%;
|
||||
/* Neo-brutalism theme colors - Dark mode */
|
||||
--background: 0 0% 7%; /* #121212 */
|
||||
--foreground: 40 15% 90%; /* #F5F2EB */
|
||||
--card: 0 0% 12%; /* #1E1E1E */
|
||||
--card-foreground: 40 15% 90%;
|
||||
--popover: 0 0% 12%;
|
||||
--popover-foreground: 40 15% 90%;
|
||||
--primary: 48 100% 50%; /* #FFD700 */
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 0 0% 20%;
|
||||
--secondary-foreground: 40 15% 90%;
|
||||
--muted: 0 0% 20%;
|
||||
--muted-foreground: 40 10% 60%;
|
||||
--accent: 0 0% 20%;
|
||||
--accent-foreground: 40 15% 90%;
|
||||
--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%;
|
||||
--border: 0 0% 29%; /* #4a4a4a */
|
||||
--input: 0 0% 29%;
|
||||
--ring: 48 100% 50%;
|
||||
--radius: 0rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark font-sans antialiased min-h-screen transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@layer components {
|
||||
/* Neo-brutalism border utility */
|
||||
.neo-border {
|
||||
@apply border-2 border-black dark:border-gray-600;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
/* Neo-brutalism card */
|
||||
.neo-card {
|
||||
@apply bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 shadow-neo dark:shadow-none;
|
||||
}
|
||||
|
||||
/* Neo-brutalism button */
|
||||
.neo-btn {
|
||||
@apply border-2 border-black dark:border-gray-500 shadow-neo-sm dark:shadow-none
|
||||
active:shadow-none active:translate-x-[2px] active:translate-y-[2px]
|
||||
transition-all duration-200 font-display font-bold uppercase;
|
||||
}
|
||||
|
||||
/* Neo-brutalism input */
|
||||
.neo-input {
|
||||
@apply border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark
|
||||
focus:ring-0 focus:border-primary dark:focus:border-primary
|
||||
font-display;
|
||||
}
|
||||
|
||||
/* Neo-brutalism tag */
|
||||
.neo-tag {
|
||||
@apply border border-black dark:border-gray-500 px-2 py-1 text-xs
|
||||
font-display font-bold uppercase bg-white dark:bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -1,9 +1,6 @@
|
||||
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 项目",
|
||||
@@ -15,8 +12,16 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body className={inter.className}>{children}</body>
|
||||
<html lang="zh" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Load fonts via CSS */}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"/>
|
||||
{/* Material Icons */}
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"/>
|
||||
</head>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,31 +16,86 @@ interface ProjectCardProps {
|
||||
}>
|
||||
}
|
||||
locale: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, locale }: ProjectCardProps) {
|
||||
// Icon mapping for projects based on tags
|
||||
function getProjectIcon(tags: Array<{ name: string }>): string {
|
||||
const tagNames = tags.map(t => t.name.toLowerCase())
|
||||
if (tagNames.some(t => t.includes('automation') || t.includes('workflow'))) return '⚙️'
|
||||
if (tagNames.some(t => t.includes('image') || t.includes('art'))) return '🎨'
|
||||
if (tagNames.some(t => t.includes('chat') || t.includes('assistant'))) return '💬'
|
||||
if (tagNames.some(t => t.includes('data') || t.includes('analysis'))) return '📊'
|
||||
if (tagNames.some(t => t.includes('llm') || t.includes('language'))) return '🧠'
|
||||
return '✨'
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, locale, featured = false }: ProjectCardProps) {
|
||||
const icon = getProjectIcon(project.tags)
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
|
||||
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>
|
||||
))}
|
||||
<article
|
||||
className={`bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-6 ${
|
||||
featured ? 'shadow-neo hover:shadow-neo-hover hover:-translate-y-1' : 'shadow-neo-sm'
|
||||
} transition-all duration-200 flex flex-col h-full group`}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-display text-xl md:text-2xl font-bold">{displayName}</h3>
|
||||
<span className="text-2xl group-hover:rotate-12 transition-transform">{icon}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Card Description */}
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 flex-grow font-sans leading-relaxed text-sm md:text-base line-clamp-3">
|
||||
{displayDescription}
|
||||
</p>
|
||||
|
||||
{/* Card Footer */}
|
||||
<div className="mt-auto">
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="bg-gray-100 dark:bg-gray-800 px-2 py-1 text-[10px] uppercase font-display font-bold border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Details Link */}
|
||||
<Link
|
||||
href={`/${locale}/projects/${project.slug}`}
|
||||
className="inline-flex items-center font-display font-bold text-sm uppercase hover:text-secondary dark:hover:text-primary group"
|
||||
>
|
||||
VIEW DETAILS{' '}
|
||||
<span className="ml-1 transform group-hover:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// Special card for "Submit Your Project" CTA
|
||||
export function SubmitProjectCard({ locale }: { locale: string }) {
|
||||
return (
|
||||
<article className="bg-primary border-2 border-black dark:border-gray-600 p-6 shadow-neo hover:shadow-neo-hover hover:-translate-y-1 transition-all duration-200 flex flex-col h-full justify-center items-center text-center group">
|
||||
<div className="bg-white dark:bg-black p-4 rounded-full border-2 border-black mb-4 group-hover:scale-110 transition-transform">
|
||||
<span className="text-3xl dark:text-primary">+</span>
|
||||
</div>
|
||||
<h3 className="font-display text-xl md:text-2xl font-bold text-black mb-2">Submit Your Project</h3>
|
||||
<p className="text-black mb-6 font-sans text-sm px-4">
|
||||
Have an AI tool worth sharing? Add it to Agent Park.
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/projects/${project.slug}`}
|
||||
className="text-primary hover:underline text-sm font-medium"
|
||||
href="#"
|
||||
className="bg-black text-white dark:bg-black dark:text-primary border-2 border-black dark:border-black px-6 py-2 font-display text-sm font-bold uppercase hover:bg-white hover:text-black dark:hover:bg-white dark:hover:text-black transition-colors"
|
||||
>
|
||||
查看详情 →
|
||||
SUBMIT NOW
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
project: {
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
description: string
|
||||
descriptionEn?: string | null
|
||||
content?: string | null
|
||||
contentEn?: string | null
|
||||
source?: string | null
|
||||
createdAt: Date
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
}>
|
||||
links: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
title?: string | null
|
||||
}>
|
||||
}
|
||||
locale: string
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
function formatDate(date: Date | string, locale: string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }
|
||||
return dateObj.toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', options)
|
||||
}
|
||||
|
||||
export function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
const displayDescription =
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
const displayContent = locale === 'en' && project.contentEn ? project.contentEn : project.content
|
||||
|
||||
// Get category from first tag
|
||||
const category = project.tags[0]?.name || 'AI Agent'
|
||||
const categoryEn = project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
|
||||
const displayCategory = locale === 'en' ? categoryEn : category
|
||||
|
||||
const handleShare = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = encodeURIComponent(window.location.href)
|
||||
const text = encodeURIComponent(`Check out ${displayName} on Agent Park`)
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header Section */}
|
||||
<header className="mb-10 relative">
|
||||
{/* Decorative shape */}
|
||||
<div
|
||||
className="absolute -top-10 -right-10 w-24 h-24 border border-black dark:border-white opacity-20 rounded-full hidden md:block pointer-events-none"
|
||||
style={{ borderRadius: '40% 60% 70% 30% / 40% 50% 60% 50%' }}
|
||||
></div>
|
||||
|
||||
<h1 className="font-display text-4xl md:text-6xl font-bold mb-6 leading-tight uppercase relative z-10">
|
||||
{displayName}
|
||||
</h1>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">calendar_today</span>
|
||||
<span>Added {formatDate(project.createdAt, locale)}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">category</span>
|
||||
<span>{displayCategory}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">code</span>
|
||||
<span>Open Source</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-3 py-1 border border-black dark:border-gray-500 text-xs font-display font-bold uppercase bg-white dark:bg-gray-800"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Featured Image/Video Placeholder */}
|
||||
<div className="relative w-full aspect-video border-2 border-black dark:border-gray-600 bg-gray-100 dark:bg-gray-800 mb-10 overflow-hidden shadow-brutal dark:shadow-brutal-dark flex items-center justify-center">
|
||||
{project.source ? (
|
||||
<iframe
|
||||
src={project.source}
|
||||
className="w-full h-full"
|
||||
title={`${displayName} demo`}
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<span className="material-icons text-6xl text-gray-400 dark:text-gray-600 mb-2">
|
||||
smart_toy
|
||||
</span>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-display text-sm">Project Preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="prose prose-lg dark:prose-invert max-w-none prose-headings:font-display prose-a:text-blue-600 dark:prose-a:text-blue-400 hover:prose-a:text-blue-800 dark:hover:prose-a:text-blue-300 prose-img:border-2 prose-img:border-black dark:prose-img:border-gray-600 prose-img:shadow-brutal dark:prose-img:shadow-none prose-ul:marker:text-black prose-ul:dark:marker:text-white">
|
||||
{/* Lead paragraph */}
|
||||
<p className="lead font-display text-xl mb-8">{displayDescription}</p>
|
||||
|
||||
{/* Full content */}
|
||||
{displayContent && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: displayContent.replace(/\n/g, '<br />'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Installation section if GitHub link exists */}
|
||||
{project.links.some((l) => l.type === 'GITHUB') && (
|
||||
<>
|
||||
<h3>Getting Started</h3>
|
||||
<p>To install this project, clone the repository and install dependencies:</p>
|
||||
<pre className="bg-gray-100 dark:bg-gray-800 border border-black dark:border-gray-600 p-4 font-mono text-sm overflow-x-auto">
|
||||
<code>{`git clone ${
|
||||
project.links.find((l) => l.type === 'GITHUB')?.url || 'https://github.com/example/project'
|
||||
}
|
||||
cd ${project.slug}
|
||||
npm install`}</code>
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Share and Feedback Section */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-300 dark:border-gray-700 flex flex-col sm:flex-row justify-between items-center gap-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Share on X"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<span className="material-icons text-lg">share</span>
|
||||
</button>
|
||||
<button
|
||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Copy Link"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
<span className="material-icons text-lg">link</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-display font-bold text-gray-500">DID THIS AGENT HELP YOU?</span>
|
||||
<button className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]">
|
||||
Yes, it rocks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectCard } from './ProjectCard'
|
||||
import { ProjectCard, SubmitProjectCard } from './ProjectCard'
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: Array<{
|
||||
@@ -16,22 +16,28 @@ interface ProjectListProps {
|
||||
}>
|
||||
}>
|
||||
locale: string
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export function ProjectList({ projects, locale }: ProjectListProps) {
|
||||
export function ProjectList({ projects, locale, featured = false }: ProjectListProps) {
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">暂无项目</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-display">暂无项目</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} locale={locale} />
|
||||
<ProjectCard key={project.id} project={project} locale={locale} featured={featured} />
|
||||
))}
|
||||
|
||||
{/* Add "Submit Your Project" CTA card for featured section */}
|
||||
{featured && (
|
||||
<SubmitProjectCard locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
project: {
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
links: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
title?: string | null
|
||||
}>
|
||||
}
|
||||
locale: string
|
||||
}
|
||||
|
||||
// Helper function to get icon and label for link type
|
||||
function getLinkInfo(type: string): { icon: string; label: string } {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'WEBSITE':
|
||||
return { icon: 'language', label: 'Official Website' }
|
||||
case 'GITHUB':
|
||||
return { icon: 'code', label: 'GitHub Repo' }
|
||||
case 'HUGGINGFACE':
|
||||
return { icon: 'model_training', label: 'Hugging Face' }
|
||||
case 'PAPER':
|
||||
return { icon: 'description', label: 'Paper' }
|
||||
default:
|
||||
return { icon: 'link', label: 'External Link' }
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
|
||||
return (
|
||||
<aside className="space-y-8">
|
||||
{/* Project Links Card */}
|
||||
{project.links.length > 0 && (
|
||||
<div className="bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-6 shadow-brutal dark:shadow-brutal-dark">
|
||||
<h3 className="font-display font-bold text-lg mb-6 uppercase border-b-2 border-primary pb-2 inline-block">
|
||||
Project Links
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{project.links.map((link) => {
|
||||
const { icon, label } = getLinkInfo(link.type)
|
||||
return (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between group p-3 border border-gray-200 dark:border-gray-700 hover:border-black dark:hover:border-white transition-colors bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-icons">{icon}</span>
|
||||
<span className="font-bold text-sm">{link.title || label}</span>
|
||||
</div>
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">
|
||||
arrow_forward
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">License</span>
|
||||
<span className="font-bold text-sm">MIT</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">Pricing</span>
|
||||
<span className="font-bold text-sm">Free</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">Status</span>
|
||||
<span className="font-bold text-sm">Active</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 uppercase font-mono block mb-1">Type</span>
|
||||
<span className="font-bold text-sm">Open Source</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Card */}
|
||||
<div className="bg-primary border-2 border-black p-6 relative overflow-hidden shadow-brutal dark:shadow-none group cursor-pointer hover:bg-yellow-400 transition-colors">
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-white opacity-20 transform rotate-45 translate-x-10 -translate-y-10"></div>
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-black opacity-10 rounded-full transform -translate-x-8 translate-y-8"></div>
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="w-12 h-12 bg-white rounded-full border-2 border-black flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
||||
<span className="material-icons text-2xl">rocket_launch</span>
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-xl mb-2">Build Your Own Agent?</h3>
|
||||
<p className="text-sm font-medium mb-6">Start using our API to build custom agents in minutes.</p>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="block w-full bg-black text-white font-display font-bold py-3 text-sm uppercase hover:bg-gray-800 transition-colors shadow-none text-center"
|
||||
>
|
||||
Get Started Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
{locale === 'en' && (
|
||||
<div className="sticky top-24 bg-background-light dark:bg-background-dark border border-gray-300 dark:border-gray-700 p-6 hidden lg:block">
|
||||
<h3 className="font-display font-bold text-sm uppercase tracking-widest mb-4 text-gray-500 dark:text-gray-400">
|
||||
On this page
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
||||
<li>
|
||||
<a
|
||||
href="#overview"
|
||||
className="text-black dark:text-white font-bold border-l-2 border-black dark:border-white -ml-[18px] pl-4 block"
|
||||
>
|
||||
Overview
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#features"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-colors block"
|
||||
>
|
||||
Key Features
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#getting-started"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-colors block"
|
||||
>
|
||||
Getting Started
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#use-cases"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-black dark:hover:text-white transition-colors block"
|
||||
>
|
||||
Use Cases
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
interface RelatedProjectsProps {
|
||||
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
|
||||
}
|
||||
|
||||
// Icon mapping for projects
|
||||
function getProjectIcon(tags: Array<{ name: string }>): string {
|
||||
const tagNames = tags.map((t) => t.name.toLowerCase())
|
||||
if (tagNames.some((t) => t.includes('automation') || t.includes('workflow'))) return '⚙️'
|
||||
if (tagNames.some((t) => t.includes('image') || t.includes('art'))) return '🎨'
|
||||
if (tagNames.some((t) => t.includes('chat') || t.includes('assistant'))) return '💬'
|
||||
if (tagNames.some((t) => t.includes('data') || t.includes('analysis'))) return '📊'
|
||||
if (tagNames.some((t) => t.includes('llm') || t.includes('language'))) return '🧠'
|
||||
return '✨'
|
||||
}
|
||||
|
||||
export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
|
||||
if (projects.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-20 pt-12 border-t border-black dark:border-gray-700">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<h2 className="font-display text-3xl font-bold uppercase">Related Projects</h2>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
|
||||
>
|
||||
View All{' '}
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{projects.map((project) => {
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
const displayDescription =
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
const icon = getProjectIcon(project.tags)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/${locale}/projects/${project.slug}`}
|
||||
className="border-2 border-black dark:border-gray-600 p-6 bg-white dark:bg-surface-dark hover:shadow-brutal hover:-translate-y-1 transition-all duration-200 cursor-pointer flex flex-col h-full dark:hover:shadow-brutal-dark group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-display text-xl font-bold">{displayName}</h3>
|
||||
<span className="text-2xl group-hover:rotate-12 transition-transform">{icon}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6 flex-grow line-clamp-3">
|
||||
{displayDescription}
|
||||
</p>
|
||||
<div className="flex gap-2 mb-6">
|
||||
{project.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs font-display font-bold uppercase flex items-center">
|
||||
View Details{' '}
|
||||
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform">
|
||||
arrow_forward
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -11,25 +11,40 @@ interface TagCloudProps {
|
||||
}
|
||||
}>
|
||||
locale: string
|
||||
activeTag?: string
|
||||
}
|
||||
|
||||
export function TagCloud({ tags, locale }: TagCloudProps) {
|
||||
export function TagCloud({ tags, locale, activeTag }: 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 className="flex flex-wrap gap-3">
|
||||
{tags.map((tag) => {
|
||||
const count = tag._count?.projects || 0
|
||||
const isActive = activeTag === tag.slug
|
||||
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||
className={`px-3 py-1 border-2 font-display text-xs font-bold uppercase transition-all shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center gap-2 group ${
|
||||
isActive
|
||||
? 'bg-black text-white border-black'
|
||||
: 'bg-white dark:bg-surface-dark border-black dark:border-gray-500 hover:bg-black hover:text-white dark:hover:bg-primary dark:hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{displayName}
|
||||
{count > 0 && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 ${
|
||||
isActive
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 group-hover:bg-white group-hover:text-black'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,14 +20,35 @@ export function SearchBar({ locale }: SearchBarProps) {
|
||||
}
|
||||
|
||||
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 onSubmit={handleSubmit} className="max-w-2xl mx-auto">
|
||||
<div className="relative group">
|
||||
{/* Glow effect on hover */}
|
||||
<div className="absolute -inset-1 bg-black dark:bg-primary rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-200"></div>
|
||||
|
||||
<div className="relative flex items-center">
|
||||
{/* Search icon */}
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-400">🔍</span>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search AI projects..."
|
||||
className="block w-full pl-12 pr-32 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
|
||||
/>
|
||||
|
||||
{/* Search button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-y-2 right-2 px-4 bg-primary text-black font-bold font-display text-sm border-2 border-black hover:bg-yellow-400 transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
||||
>
|
||||
SEARCH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user