feat: improve production site trust flows

This commit is contained in:
2026-04-26 10:50:46 +08:00
parent 3e368e663e
commit 68d4f09b72
41 changed files with 1584 additions and 225 deletions
@@ -0,0 +1,21 @@
CREATE TYPE "ProjectSubmissionStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'IMPORTED');
CREATE TABLE "project_submissions" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"normalizedUrl" TEXT NOT NULL,
"projectName" TEXT,
"description" TEXT,
"submitterName" TEXT,
"submitterEmail" TEXT,
"locale" TEXT NOT NULL DEFAULT 'zh',
"status" "ProjectSubmissionStatus" NOT NULL DEFAULT 'PENDING',
"reviewNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "project_submissions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "project_submissions_normalizedUrl_key" ON "project_submissions"("normalizedUrl");
CREATE INDEX "idx_project_submission_status_createdAt" ON "project_submissions"("status", "createdAt");
CREATE INDEX "idx_project_submission_locale" ON "project_submissions"("locale");
+26
View File
@@ -130,6 +130,25 @@ model Signal {
@@map("signals") @@map("signals")
} }
model ProjectSubmission {
id String @id @default(cuid())
url String
normalizedUrl String @unique
projectName String?
description String?
submitterName String?
submitterEmail String?
locale String @default("zh")
status ProjectSubmissionStatus @default(PENDING)
reviewNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt], map: "idx_project_submission_status_createdAt")
@@index([locale], map: "idx_project_submission_locale")
@@map("project_submissions")
}
enum LinkType { enum LinkType {
WEBSITE WEBSITE
GITHUB GITHUB
@@ -149,6 +168,13 @@ enum TaskStatus {
FAILED FAILED
} }
enum ProjectSubmissionStatus {
PENDING
APPROVED
REJECTED
IMPORTED
}
enum TagCategory { enum TagCategory {
FIXED_PROJECT_TYPE FIXED_PROJECT_TYPE
TECH_STACK TECH_STACK
+6 -6
View File
@@ -88,7 +88,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pt-14 md:pt-20 pb-12 text-center"> <section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pt-14 md:pt-20 pb-12 text-center">
<p className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-black border-2 border-black font-display text-xs font-bold uppercase tracking-wide shadow-neo-sm"> <p className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-black border-2 border-black font-display text-xs font-bold uppercase tracking-wide shadow-neo-sm">
<span className="material-icons text-base">hub</span> <span className="material-icons text-base" aria-hidden="true">hub</span>
{t('heroEyebrow')} {t('heroEyebrow')}
</p> </p>
<h1 className="mt-6 font-display text-4xl md:text-6xl font-bold leading-tight tracking-tight"> <h1 className="mt-6 font-display text-4xl md:text-6xl font-bold leading-tight tracking-tight">
@@ -109,7 +109,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t('ctaProjects')} {t('ctaProjects')}
</Link> </Link>
<Link <Link
href={`/${locale}/projects`} href={`/${locale}/submit`}
className="neo-btn bg-white dark:bg-surface-dark px-6 py-3 text-sm" className="neo-btn bg-white dark:bg-surface-dark px-6 py-3 text-sm"
> >
{t('ctaSubmit')} {t('ctaSubmit')}
@@ -135,7 +135,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{capabilityCards.map((card) => ( {capabilityCards.map((card) => (
<article key={card.title} className="neo-card p-6 md:p-7"> <article key={card.title} className="neo-card p-6 md:p-7">
<span className="material-icons text-3xl mb-4">{card.icon}</span> <span className="material-icons text-3xl mb-4" aria-hidden="true">{card.icon}</span>
<h3 className="font-display text-lg font-bold mb-2">{card.title}</h3> <h3 className="font-display text-lg font-bold mb-2">{card.title}</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{card.description}</p> <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{card.description}</p>
</article> </article>
@@ -151,7 +151,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
{workflowSteps.map((step, index) => ( {workflowSteps.map((step, index) => (
<article key={step.title} className="border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark p-4"> <article key={step.title} className="border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark p-4">
<div className="font-display text-xs font-bold uppercase mb-2">0{index + 1}</div> <div className="font-display text-xs font-bold uppercase mb-2">0{index + 1}</div>
<span className="material-icons text-2xl mb-2">{step.icon}</span> <span className="material-icons text-2xl mb-2" aria-hidden="true">{step.icon}</span>
<h3 className="font-display text-base font-bold mb-1">{step.title}</h3> <h3 className="font-display text-base font-bold mb-1">{step.title}</h3>
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">{step.description}</p> <p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">{step.description}</p>
</article> </article>
@@ -165,7 +165,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{principles.map((principle) => ( {principles.map((principle) => (
<article key={principle.title} className="neo-card p-6 md:p-7"> <article key={principle.title} className="neo-card p-6 md:p-7">
<span className="material-icons text-3xl mb-4">{principle.icon}</span> <span className="material-icons text-3xl mb-4" aria-hidden="true">{principle.icon}</span>
<h3 className="font-display text-lg font-bold mb-2">{principle.title}</h3> <h3 className="font-display text-lg font-bold mb-2">{principle.title}</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{principle.description}</p> <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{principle.description}</p>
</article> </article>
@@ -177,7 +177,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<div className="neo-card bg-primary text-black p-8 md:p-10 text-center"> <div className="neo-card bg-primary text-black p-8 md:p-10 text-center">
<h2 className="font-display text-2xl md:text-3xl font-bold mb-3">{t('closingTitle')}</h2> <h2 className="font-display text-2xl md:text-3xl font-bold mb-3">{t('closingTitle')}</h2>
<p className="max-w-2xl mx-auto mb-6 leading-relaxed">{t('closingDescription')}</p> <p className="max-w-2xl mx-auto mb-6 leading-relaxed">{t('closingDescription')}</p>
<Link href={`/${locale}/projects`} className="neo-btn inline-flex bg-white text-black px-6 py-3 text-sm"> <Link href={`/${locale}/submit`} className="neo-btn inline-flex bg-white text-black px-6 py-3 text-sm">
{t('closingCta')} {t('closingCta')}
</Link> </Link>
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
interface PageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.docs" });
return { title: t("title"), description: t("description") };
}
export default async function DocsPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.docs" });
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
return (
<StaticInfoPage
eyebrow="Agent Park"
title={t("title")}
description={t("description")}
sections={sections}
/>
);
}
+30 -103
View File
@@ -2,8 +2,9 @@ import { notFound } from "next/navigation"
import { NextIntlClientProvider } from 'next-intl' import { NextIntlClientProvider } from 'next-intl'
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server' import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server'
import Link from "next/link" import Link from "next/link"
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
import { AnnouncementBar } from "@/components/layout/AnnouncementBar" import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
import { NewsletterSignup } from "@/components/layout/NewsletterSignup"
import { SiteHeader } from "@/components/layout/SiteHeader"
import type { Metadata } from "next" import type { Metadata } from "next"
const locales = ['zh', 'en'] const locales = ['zh', 'en']
@@ -54,69 +55,18 @@ export default async function LocaleLayout({
closeLabel={t('closeAnnouncement')} closeLabel={t('closeAnnouncement')}
/> />
{/* Navigation */} <SiteHeader
<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"> locale={locale}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> labels={{
<div className="flex justify-between items-center h-16"> home: tNav('home'),
{/* Logo */} projects: tNav('projects'),
<div className="flex-shrink-0 flex items-center gap-2"> signals: tNav('signals'),
<span className="material-icons text-3xl">smart_toy</span> about: tNav('about'),
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight"> submitProject: tNav('submitProject'),
Agent Park menu: tNav('menu'),
</Link> closeMenu: tNav('closeMenu'),
</div> }}
/>
{/* 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}`}
>
{tNav('home')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/projects`}
>
{tNav('projects')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/signals`}
>
{tNav('signals')}
</Link>
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/about`}
>
{tNav('about')}
</Link>
</nav>
{/* Right side */}
<div className="hidden md:flex items-center space-x-4">
<LocaleSwitcher
currentLocale={locale}
switchUrl={`/${locale === 'zh' ? 'en' : 'zh'}`}
/>
<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="#"
>
{tNav('submitProject')}
</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 relative">{children}</main> <main className="flex-1 relative">{children}</main>
@@ -128,36 +78,21 @@ export default async function LocaleLayout({
<p className="font-sans text-black mb-8 max-w-md"> <p className="font-sans text-black mb-8 max-w-md">
{t('stayUpdatedDesc')} {t('stayUpdatedDesc')}
</p> </p>
<form className="space-y-4 max-w-md"> <NewsletterSignup
<input labels={{
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" email: t('email'),
placeholder={t('email')} subscribe: t('subscribe'),
type="email" consent: t('subscribeConsent'),
id="newsletter-email" unavailable: t('newsletterUnavailable'),
name="email" }}
autoComplete="email" />
required
/>
<div className="flex items-center gap-2">
<input className="w-4 h-4 border-2 border-black text-black focus:ring-0" id="consent" name="consent" type="checkbox" required />
<label className="text-xs font-bold font-display text-black" htmlFor="consent">
{t('subscribeConsent')}
</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"
>
{t('subscribe')}
</button>
</form>
</div> </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="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="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-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 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"> <div className="absolute inset-0 flex items-center justify-center z-10">
<span className="material-icons text-8xl">rocket_launch</span> <span className="material-icons text-8xl" aria-hidden="true">rocket_launch</span>
</div> </div>
</div> </div>
</div> </div>
@@ -167,10 +102,10 @@ export default async function LocaleLayout({
{/* Footer */} {/* Footer */}
<footer className="bg-background-light dark:bg-background-dark border-t-2 border-black dark:border-gray-700 py-12"> <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="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="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
<div className="col-span-1 md:col-span-1"> <div className="col-span-1 md:col-span-1">
<Link className="flex items-center gap-2 mb-4" href={`/${locale}`}> <Link className="flex items-center gap-2 mb-4" href={`/${locale}`}>
<span className="material-icons text-2xl">smart_toy</span> <span className="material-icons text-2xl" aria-hidden="true">smart_toy</span>
<span className="font-display font-bold text-lg tracking-tight">Agent Park</span> <span className="font-display font-bold text-lg tracking-tight">Agent Park</span>
</Link> </Link>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
@@ -180,26 +115,18 @@ export default async function LocaleLayout({
<div> <div>
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4> <h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400"> <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="#">{t('resourceNewsletter')}</Link></li> <li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/newsletter`}>{t('resourceNewsletter')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceUpdates')}</Link></li> <li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/updates`}>{t('resourceUpdates')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceDocumentation')}</Link></li> <li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/docs`}>{t('resourceDocumentation')}</Link></li>
</ul> </ul>
</div> </div>
<div> <div>
<h4 className="font-display font-bold text-lg mb-4">{t('legal')}</h4> <h4 className="font-display font-bold text-lg mb-4">{t('legal')}</h4>
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400"> <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="#">{t('privacyPolicy')}</Link></li> <li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/privacy`}>{t('privacyPolicy')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('termsOfService')}</Link></li> <li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/terms`}>{t('termsOfService')}</Link></li>
</ul> </ul>
</div> </div>
<div>
<h4 className="font-display font-bold text-lg mb-4">{t('followUs')}</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>
<div className="border-t border-black dark:border-gray-700 pt-8 flex flex-col md:flex-row justify-between items-center"> <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">{t('copyright')}</p> <p className="text-sm text-gray-500 font-display">{t('copyright')}</p>
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
interface PageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.newsletter" });
return { title: t("title"), description: t("description") };
}
export default async function NewsletterPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.newsletter" });
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
return (
<StaticInfoPage
eyebrow="Agent Park"
title={t("title")}
description={t("description")}
sections={sections}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
interface PageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.privacy" });
return { title: t("title"), description: t("description") };
}
export default async function PrivacyPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.privacy" });
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
return (
<StaticInfoPage
eyebrow="Agent Park"
title={t("title")}
description={t("description")}
sections={sections}
/>
);
}
@@ -77,7 +77,7 @@ export function ProjectsPageClient({
} }
if (selectedTags.length > 0) params.set('tags', selectedTags.join(',')) if (selectedTags.length > 0) params.set('tags', selectedTags.join(','))
if (sort !== 'latest') params.set('sort', sort) if (sort !== 'latest') params.set('sort', sort)
if (limit !== 20) params.set('limit', String(limit)) if (limit !== 10) params.set('limit', String(limit))
if (useAI) params.set('ai', '1') if (useAI) params.set('ai', '1')
router.push(`/${locale}/projects?${params.toString()}`) router.push(`/${locale}/projects?${params.toString()}`)
}, },
@@ -24,6 +24,8 @@ type AISearchPagination = {
hasMore: boolean hasMore: boolean
} }
type AIFallbackReason = 'error' | 'empty' | null
type ProjectsPagination = { type ProjectsPagination = {
total: number total: number
page: number page: number
@@ -69,6 +71,8 @@ interface ProjectsResultsClientProps {
noProjects: string noProjects: string
noResults: string noResults: string
searching: string searching: string
aiSearchUnavailableFallback: string
aiSearchEmptyFallback: string
} }
} }
@@ -168,6 +172,15 @@ export function ProjectsResultsClient({
const [aiResults, setAiResults] = useState<AISearchResult[]>([]) const [aiResults, setAiResults] = useState<AISearchResult[]>([])
const [loadingAI, setLoadingAI] = useState(false) const [loadingAI, setLoadingAI] = useState(false)
const [aiError, setAiError] = useState<string | null>(null) const [aiError, setAiError] = useState<string | null>(null)
const [aiFallbackReason, setAiFallbackReason] = useState<AIFallbackReason>(null)
const [aiFallbackResults, setAiFallbackResults] = useState<ProjectListItem[]>([])
const [loadingAIFallback, setLoadingAIFallback] = useState(false)
const [aiFallbackPagination, setAiFallbackPagination] = useState<ProjectsPagination>({
total: 0,
page: initialPage,
limit: initialLimit,
totalPages: 0,
})
const [aiCurrentPage, setAiCurrentPage] = useState(initialPage) const [aiCurrentPage, setAiCurrentPage] = useState(initialPage)
const [aiSort, setAiSort] = useState<ProjectSortOption>(sort) const [aiSort, setAiSort] = useState<ProjectSortOption>(sort)
const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit) const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit)
@@ -260,6 +273,9 @@ export function ProjectsResultsClient({
setTraditionalLimit(nextLimit) setTraditionalLimit(nextLimit)
setTraditionalError(null) setTraditionalError(null)
setLoadingTraditional(false) setLoadingTraditional(false)
setAiFallbackReason(null)
setAiFallbackResults([])
setLoadingAIFallback(false)
}, [ }, [
isAI, isAI,
page, page,
@@ -379,6 +395,92 @@ export function ProjectsResultsClient({
[locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags] [locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
) )
const fetchAIFallbackResults = useCallback(
async (
nextPage: number,
nextSort: ProjectSortOption,
nextLimit: (typeof PAGE_SIZE_OPTIONS)[number],
reason: Exclude<AIFallbackReason, null>
): Promise<boolean> => {
setLoadingAIFallback(true)
try {
const query = new URLSearchParams({
page: String(nextPage),
limit: String(nextLimit),
sort: nextSort,
})
const normalizedSearch = search.trim()
if (normalizedSearch) query.set('search', normalizedSearch)
if (projectType) query.set('projectType', projectType)
if (selectedDomains.length > 0) query.set('domains', selectedDomains.join(','))
if (selectedProductForms.length > 0) {
query.set('productForms', selectedProductForms.join(','))
}
if (selectedTags.length > 0) query.set('tags', selectedTags.join(','))
const response = await fetch(`/api/projects?${query.toString()}`, {
method: 'GET',
})
if (!response.ok) {
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
throw new Error(`${projectsFetchFailedText} (${response.status})`)
}
const data = await response.json()
const incomingProjects = Array.isArray(data?.projects)
? (data.projects as ProjectListItem[])
: []
const incomingPagination = data?.pagination as Partial<ProjectsPagination> | undefined
const normalizedTotal =
typeof incomingPagination?.total === 'number'
? incomingPagination.total
: incomingProjects.length
const normalizedTotalPages = Math.max(
0,
typeof incomingPagination?.totalPages === 'number'
? incomingPagination.totalPages
: normalizedTotal === 0
? 0
: Math.ceil(normalizedTotal / nextLimit)
)
const safePage = normalizedTotalPages === 0 ? 1 : Math.min(nextPage, normalizedTotalPages)
setAiFallbackResults(incomingProjects)
setAiFallbackPagination({
total: normalizedTotal,
page: safePage,
limit:
typeof incomingPagination?.limit === 'number'
? normalizePageLimit(incomingPagination.limit)
: nextLimit,
totalPages: normalizedTotalPages,
})
setAiFallbackReason(reason)
setAiError(null)
return true
} catch (error) {
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
setAiError(error instanceof Error ? error.message : projectsFetchFailedText)
setAiFallbackReason(null)
setAiFallbackResults([])
setAiFallbackPagination({
total: 0,
page: 1,
limit: nextLimit,
totalPages: 0,
})
return false
} finally {
setLoadingAIFallback(false)
}
},
[locale, projectType, search, selectedDomains, selectedProductForms, selectedTags]
)
const handleTraditionalPageChange = useCallback( const handleTraditionalPageChange = useCallback(
(nextPage: number) => { (nextPage: number) => {
const safePage = Math.max(1, nextPage) const safePage = Math.max(1, nextPage)
@@ -415,6 +517,8 @@ export function ProjectsResultsClient({
setLoadingAI(true) setLoadingAI(true)
setAiError(null) setAiError(null)
setAiFallbackReason(null)
setAiFallbackResults([])
try { try {
const response = await fetch('/api/search/ai', { const response = await fetch('/api/search/ai', {
@@ -477,11 +581,23 @@ export function ProjectsResultsClient({
setAiCurrentPage(safePage) setAiCurrentPage(safePage)
replaceProjectsUrl(safePage, aiSort, true, aiLimit) replaceProjectsUrl(safePage, aiSort, true, aiLimit)
} }
if (normalizedResults.length === 0) {
void fetchAIFallbackResults(1, aiSort, aiLimit, 'empty')
}
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败' const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
setAiError(error instanceof Error ? error.message : aiSearchFailedText) const fallbackWorked = await fetchAIFallbackResults(
aiCurrentPage,
aiSort,
aiLimit,
'error'
)
setAiError(
fallbackWorked ? null : error instanceof Error ? error.message : aiSearchFailedText
)
setAiResults([]) setAiResults([])
setAiPagination((prev) => ({ setAiPagination((prev) => ({
...prev, ...prev,
@@ -506,6 +622,7 @@ export function ProjectsResultsClient({
aiCurrentPage, aiCurrentPage,
aiLimit, aiLimit,
aiSort, aiSort,
fetchAIFallbackResults,
isAI, isAI,
locale, locale,
replaceProjectsUrl, replaceProjectsUrl,
@@ -518,7 +635,8 @@ export function ProjectsResultsClient({
selectedTagsKey, selectedTagsKey,
]) ])
const loading = isAI ? loadingAI : loadingTraditional const isUsingAIFallback = isAI && aiFallbackReason !== null
const loading = isAI ? loadingAI || loadingAIFallback : loadingTraditional
const activeSort = isAI ? aiSort : traditionalSort const activeSort = isAI ? aiSort : traditionalSort
const activePage = isAI ? aiCurrentPage : traditionalCurrentPage const activePage = isAI ? aiCurrentPage : traditionalCurrentPage
const activeLimit = isAI ? aiLimit : traditionalLimit const activeLimit = isAI ? aiLimit : traditionalLimit
@@ -528,16 +646,38 @@ export function ProjectsResultsClient({
const nextStarSort: ProjectSortOption = currentStarSort === 'stars_desc' ? 'stars_asc' : 'stars_desc' const nextStarSort: ProjectSortOption = currentStarSort === 'stars_desc' ? 'stars_asc' : 'stars_desc'
const starSortTarget: ProjectSortOption = activeSort === 'latest' ? 'stars_desc' : nextStarSort const starSortTarget: ProjectSortOption = activeSort === 'latest' ? 'stars_desc' : nextStarSort
const totalCount = isAI ? aiPagination.total : traditionalPagination.total const totalCount = isAI
? isUsingAIFallback
? aiFallbackPagination.total
: aiPagination.total
: traditionalPagination.total
const visibleProjectsLabel = loading ? '...' : String(totalCount) const visibleProjectsLabel = loading ? '...' : String(totalCount)
const projectsCountText = const projectsCountText =
locale === 'en' ? `${visibleProjectsLabel} projects` : `${visibleProjectsLabel} 个项目` locale === 'en' ? `${visibleProjectsLabel} projects` : `${visibleProjectsLabel} 个项目`
const currentPage = isAI ? aiPagination.page || activePage : traditionalPagination.page || activePage const currentPage = isAI
const activeTotalPages = isAI ? aiPagination.totalPages : traditionalPagination.totalPages ? isUsingAIFallback
? aiFallbackPagination.page || activePage
: aiPagination.page || activePage
: traditionalPagination.page || activePage
const activeTotalPages = isAI
? isUsingAIFallback
? aiFallbackPagination.totalPages
: aiPagination.totalPages
: traditionalPagination.totalPages
const canGoPrev = currentPage > 1 const canGoPrev = currentPage > 1
const canGoNext = currentPage < activeTotalPages const canGoNext = currentPage < activeTotalPages
const displayProjects = isAI ? [] : traditionalResults const displayProjects = isAI
? isUsingAIFallback
? aiFallbackResults
: []
: traditionalResults
const activeError = isAI ? aiError : traditionalError const activeError = isAI ? aiError : traditionalError
const fallbackMessage =
aiFallbackReason === 'error'
? translations.aiSearchUnavailableFallback
: aiFallbackReason === 'empty'
? translations.aiSearchEmptyFallback
: null
const totalPagesForSummary = totalCount === 0 ? 0 : Math.max(1, activeTotalPages) const totalPagesForSummary = totalCount === 0 ? 0 : Math.max(1, activeTotalPages)
const currentPageForSummary = totalPagesForSummary === 0 ? 0 : Math.min(currentPage, totalPagesForSummary) const currentPageForSummary = totalPagesForSummary === 0 ? 0 : Math.min(currentPage, totalPagesForSummary)
@@ -607,11 +747,36 @@ export function ProjectsResultsClient({
</div> </div>
) : null} ) : null}
{fallbackMessage ? (
<div className="mb-8 border-l-4 border-primary bg-yellow-50 px-4 py-3 dark:bg-yellow-900/20">
<p className="font-display text-sm font-bold text-black dark:text-yellow-100">
{fallbackMessage}
</p>
</div>
) : null}
{isAI ? ( {isAI ? (
loadingAI ? ( loading ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.searching}</p> <p className="text-gray-500 dark:text-gray-400 font-display">{translations.searching}</p>
</div> </div>
) : isUsingAIFallback ? (
displayProjects.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.noProjects}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{displayProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
locale={locale}
translations={{ viewDetails: translations.viewDetails }}
/>
))}
</div>
)
) : ( ) : (
<AISearchResults <AISearchResults
results={aiResults} results={aiResults}
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function ProjectDetailPage({
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" 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`} href={`/${locale}/projects`}
> >
<span className="material-icons text-base mr-1 group-hover:-translate-x-1 transition-transform"> <span className="material-icons text-base mr-1 group-hover:-translate-x-1 transition-transform" aria-hidden="true">
arrow_back arrow_back
</span> </span>
{tProject('backToProjects')} {tProject('backToProjects')}
+2
View File
@@ -198,6 +198,8 @@ export default async function ProjectsPage({
noProjects: tCommon('noProjects'), noProjects: tCommon('noProjects'),
noResults: tCommon('noResults'), noResults: tCommon('noResults'),
searching: tCommon('searching'), searching: tCommon('searching'),
aiSearchUnavailableFallback: tProject('aiSearchUnavailableFallback'),
aiSearchEmptyFallback: tProject('aiSearchEmptyFallback'),
}} }}
/> />
</main> </main>
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ProjectSubmissionForm } from "@/components/submissions/ProjectSubmissionForm";
interface SubmitPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: SubmitPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "submit" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
export default async function SubmitPage({ params }: SubmitPageProps) {
const { locale } = await params;
const t = await getTranslations("submit");
return (
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-14 md:py-20">
<section className="mb-8">
<p className="inline-flex border-2 border-black bg-primary px-3 py-1 font-display text-xs font-bold uppercase text-black">
{t("eyebrow")}
</p>
<h1 className="mt-5 font-display text-4xl md:text-6xl font-bold tracking-tight">
{t("title")}
</h1>
<p className="mt-4 max-w-3xl text-gray-700 dark:text-gray-300">
{t("description")}
</p>
</section>
<ProjectSubmissionForm
locale={locale}
labels={{
url: t("url"),
projectName: t("projectName"),
description: t("projectDescription"),
submitterName: t("submitterName"),
submitterEmail: t("submitterEmail"),
submit: t("submit"),
submitting: t("submitting"),
success: t("success"),
duplicate: t("duplicate"),
invalid: t("invalid"),
failed: t("failed"),
}}
/>
</main>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
interface PageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.terms" });
return { title: t("title"), description: t("description") };
}
export default async function TermsPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.terms" });
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
return (
<StaticInfoPage
eyebrow="Agent Park"
title={t("title")}
description={t("description")}
sections={sections}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
interface PageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.updates" });
return { title: t("title"), description: t("description") };
}
export default async function UpdatesPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "staticPages.updates" });
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
return (
<StaticInfoPage
eyebrow="Agent Park"
title={t("title")}
description={t("description")}
sections={sections}
/>
);
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Prisma } from "@prisma/client";
import { POST } from "./route";
const { createMock } = vi.hoisted(() => ({
createMock: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
projectSubmission: {
create: createMock,
},
},
}));
function buildRequest(body: unknown): Request {
return new Request("http://localhost:3000/api/project-submissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/project-submissions", () => {
beforeEach(() => {
createMock.mockReset();
});
it("creates a pending project submission", async () => {
createMock.mockResolvedValueOnce({
id: "submission_1",
status: "PENDING",
});
const response = await POST(
buildRequest({
url: "HTTPS://GitHub.com/Owner/Repo/?utm_source=agentpark#readme",
projectName: "Agent Repo",
locale: "en",
})
);
await expect(response.json()).resolves.toEqual({
ok: true,
id: "submission_1",
status: "PENDING",
});
expect(response.status).toBe(201);
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
url: "HTTPS://GitHub.com/Owner/Repo/?utm_source=agentpark#readme",
normalizedUrl: "https://github.com/Owner/Repo",
projectName: "Agent Repo",
locale: "en",
}),
})
);
});
it("returns 400 for invalid submissions", async () => {
const response = await POST(buildRequest({ url: "mailto:test@example.com" }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.ok).toBe(false);
expect(body.error).toBe("invalid_submission");
});
it("returns 409 for duplicate normalized URLs", async () => {
createMock.mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
})
);
const response = await POST(
buildRequest({
url: "https://github.com/example/project",
})
);
await expect(response.json()).resolves.toEqual({
ok: false,
error: "duplicate_submission",
});
expect(response.status).toBe(409);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { prisma } from "@/lib/prisma";
import {
ProjectSubmissionInputSchema,
normalizeSubmissionUrl,
} from "@/lib/project-submissions";
export async function POST(request: Request) {
try {
const body = await request.json();
const validated = ProjectSubmissionInputSchema.parse(body);
const normalizedUrl = normalizeSubmissionUrl(validated.url);
const submission = await prisma.projectSubmission.create({
data: {
url: validated.url,
normalizedUrl,
projectName: validated.projectName,
description: validated.description,
submitterName: validated.submitterName,
submitterEmail: validated.submitterEmail,
locale: validated.locale,
},
select: {
id: true,
status: true,
},
});
return NextResponse.json(
{
ok: true,
id: submission.id,
status: submission.status,
},
{ status: 201 }
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return NextResponse.json(
{
ok: false,
error: "duplicate_submission",
},
{ status: 409 }
);
}
if (error instanceof ZodError) {
return NextResponse.json(
{
ok: false,
error: "invalid_submission",
details: error.errors,
},
{ status: 400 }
);
}
console.error("Project submission error:", error);
return NextResponse.json(
{
ok: false,
error: "submission_failed",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";
async function loadPost(webhookUrl?: string) {
vi.resetModules();
vi.doMock("@/hooks/useProjects", () => ({
getProjectsByIds: vi.fn(),
}));
if (webhookUrl) {
process.env.N8N_AI_SEARCH_WEBHOOK = webhookUrl;
} else {
delete process.env.N8N_AI_SEARCH_WEBHOOK;
}
const route = await import("./route");
return route.POST;
}
function buildRequest(body: unknown): Request {
return new Request("http://localhost:3000/api/search/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/search/ai", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
delete process.env.N8N_AI_SEARCH_WEBHOOK;
});
it("returns a fallback-recommended 503 when webhook is not configured", async () => {
const POST = await loadPost();
const response = await POST(buildRequest({ search: "claude", page: 1, limit: 10 }));
const body = await response.json();
expect(response.status).toBe(503);
expect(body).toMatchObject({
error: "ai_search_unavailable",
fallbackRecommended: true,
});
});
it("returns a fallback-recommended 502 when the upstream workflow fails", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce(
new Response('{"message":"Error in workflow"}', {
status: 500,
statusText: "Internal Server Error",
})
);
const POST = await loadPost("https://n8n.example.test/search");
const response = await POST(buildRequest({ search: "claude", page: 1, limit: 10 }));
const body = await response.json();
expect(response.status).toBe(502);
expect(body).toMatchObject({
error: "ai_search_upstream_failed",
fallbackRecommended: true,
});
});
});
+26 -8
View File
@@ -3,11 +3,7 @@ import { ZodError, z } from 'zod'
import { ProjectQuerySchema } from '@/lib/validations' import { ProjectQuerySchema } from '@/lib/validations'
import { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects' import { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects'
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK! const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK
if (!N8N_WEBHOOK_URL) {
throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')
}
// n8n 返回的搜索结果 Schema(统一格式) // n8n 返回的搜索结果 Schema(统一格式)
const N8NSearchResponseSchema = z.object({ const N8NSearchResponseSchema = z.object({
@@ -55,6 +51,17 @@ export async function POST(request: Request) {
// 验证查询参数 // 验证查询参数
const validatedQuery = AISearchRequestSchema.parse(body) const validatedQuery = AISearchRequestSchema.parse(body)
if (!N8N_WEBHOOK_URL) {
return NextResponse.json(
{
error: 'ai_search_unavailable',
message: 'AI search webhook is not configured',
fallbackRecommended: true,
},
{ status: 503 }
)
}
const page = Math.max(1, validatedQuery.page) const page = Math.max(1, validatedQuery.page)
const limit = Math.max(1, validatedQuery.limit) const limit = Math.max(1, validatedQuery.limit)
const fetchLimit = Math.min(100, Math.max(page * limit + 1, limit + 1)) const fetchLimit = Math.min(100, Math.max(page * limit + 1, limit + 1))
@@ -91,7 +98,14 @@ export async function POST(request: Request) {
if (!n8nResponse.ok) { if (!n8nResponse.ok) {
const errorText = await n8nResponse.text() const errorText = await n8nResponse.text()
throw new Error(`n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`) return NextResponse.json(
{
error: 'ai_search_upstream_failed',
message: `n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`,
fallbackRecommended: true,
},
{ status: 502 }
)
} }
// n8n 返回数据 // n8n 返回数据
@@ -217,8 +231,12 @@ export async function POST(request: Request) {
} }
return NextResponse.json( return NextResponse.json(
{ error: 'AI search failed', message: error instanceof Error ? error.message : 'Unknown error' }, {
{ status: 500 } error: 'ai_search_failed',
message: error instanceof Error ? error.message : 'Unknown error',
fallbackRecommended: true,
},
{ status: 503 }
) )
} }
} }
+41
View File
@@ -271,6 +271,10 @@ export async function GET(request: NextRequest) {
} }
: {}), : {}),
} }
const facetWhere: Prisma.SignalWhereInput = {
...where,
source: undefined,
}
const hasHotColumns = await supportsSignalHotColumns(prisma) const hasHotColumns = await supportsSignalHotColumns(prisma)
const orderByWithHot: Prisma.SignalOrderByWithRelationInput[] = const orderByWithHot: Prisma.SignalOrderByWithRelationInput[] =
@@ -282,6 +286,30 @@ export async function GET(request: NextRequest) {
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
: [{ publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
const [totalCount, newestSignal, sourceCountRows, hotCount] = await Promise.all([
prisma.signal.count({ where }),
prisma.signal.findFirst({
where,
orderBy: { publishedAt: 'desc' },
select: { publishedAt: true },
}),
prisma.signal.groupBy({
by: ['source'] as const,
where: facetWhere,
_count: {
_all: true,
},
}),
hasHotColumns
? prisma.signal.count({
where: {
...where,
isHot: true,
},
})
: Promise.resolve(0),
])
let rows: SignalQueryRow[] let rows: SignalQueryRow[]
if (hasHotColumns) { if (hasHotColumns) {
@@ -331,6 +359,19 @@ export async function GET(request: NextRequest) {
items, items,
nextCursor, nextCursor,
hasMore, hasMore,
meta: {
totalCount,
hotCount,
newestPublishedAt: newestSignal?.publishedAt.toISOString() || null,
},
facets: {
sourceCounts: sourceCountRows.reduce<Record<string, number>>((acc, row) => {
if (isSignalSource(row.source)) {
acc[row.source] = row._count._all
}
return acc
}, {}),
},
}) })
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { MetadataRoute } from 'next' import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.ai' const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.fun'
return { return {
rules: [ rules: [
+39 -42
View File
@@ -1,55 +1,52 @@
import { MetadataRoute } from 'next' import { MetadataRoute } from 'next'
import { getProjects } from '@/hooks/useProjects' import { prisma } from '@/lib/prisma'
const locales = ['zh', 'en'] as const
const staticPaths = ['', '/projects', '/signals', '/about', '/submit', '/newsletter', '/updates', '/docs', '/privacy', '/terms'] as const
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.ai' const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.fun'
// 获取所有项目 let projects: Array<{ slug: string; updatedAt: Date }> = []
const { projects } = await getProjects({ limit: 1000 }) try {
projects = await prisma.project.findMany({
where: {
status: 'ACTIVE',
},
select: {
slug: true,
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
})
} catch (error) {
console.error(
'[sitemap] degraded to static pages:',
error instanceof Error ? error.message : String(error)
)
}
// 静态页面 const now = new Date()
const staticPages: MetadataRoute.Sitemap = [ const staticPages: MetadataRoute.Sitemap = locales.flatMap((locale) =>
{ staticPaths.map((path) => ({
url: `${baseUrl}/zh`, url: `${baseUrl}/${locale}${path}`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'daily', changeFrequency: 'daily' as const,
priority: 1, priority: path === '' ? 1 : path === '/projects' ? 0.9 : 0.7,
}, }))
{ )
url: `${baseUrl}/en`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/zh/projects`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/en/projects`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
]
// 项目详情页(中英文) // 项目详情页(中英文)
const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) => [ const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) =>
{ locales.map((locale) => ({
url: `${baseUrl}/zh/projects/${project.slug}`, url: `${baseUrl}/${locale}/projects/${project.slug}`,
lastModified: project.updatedAt, lastModified: project.updatedAt,
changeFrequency: 'weekly' as const, changeFrequency: 'weekly' as const,
priority: 0.8, priority: 0.8,
}, }))
{ )
url: `${baseUrl}/en/projects/${project.slug}`,
lastModified: project.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
},
])
return [...staticPages, ...projectPages] return [...staticPages, ...projectPages]
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import type { HomeProjectSummary } from '@/hooks/useHome' import type { HomeProjectSummary } from '@/hooks/useHome'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
interface HomeRecentTimelineProps { interface HomeRecentTimelineProps {
locale: string locale: string
@@ -66,7 +67,7 @@ export function HomeRecentTimeline({
href={`/${locale}/projects?tag=${tag.slug}`} href={`/${locale}/projects?tag=${tag.slug}`}
className="px-2 py-1 border border-black dark:border-gray-600 text-[10px] uppercase font-display font-bold bg-white dark:bg-surface-dark" className="px-2 py-1 border border-black dark:border-gray-600 text-[10px] uppercase font-display font-bold bg-white dark:bg-surface-dark"
> >
{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name} {getLocalizedTagName(tag, locale)}
</Link> </Link>
))} ))}
</div> </div>
+2 -1
View File
@@ -1,4 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
interface HomeTagInsightsProps { interface HomeTagInsightsProps {
locale: string locale: string
@@ -48,7 +49,7 @@ export function HomeTagInsights({
href={`/${locale}/projects?tag=${tag.slug}`} href={`/${locale}/projects?tag=${tag.slug}`}
className="shrink-0 px-3 py-2 border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark text-xs font-display font-bold uppercase shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all" className="shrink-0 px-3 py-2 border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark text-xs font-display font-bold uppercase shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
> >
<span>{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}</span> <span>{getLocalizedTagName(tag, locale)}</span>
<span className="ml-2 text-gray-500 dark:text-gray-400"> <span className="ml-2 text-gray-500 dark:text-gray-400">
{formatNumber(tag.projectCount, locale)} {formatNumber(tag.projectCount, locale)}
</span> </span>
+2 -2
View File
@@ -38,7 +38,7 @@ export function AnnouncementBar({ text, href, locale, closeLabel }: Announcement
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 hover:opacity-90 transition-opacity relative" 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 hover:opacity-90 transition-opacity relative"
> >
<span>{text}</span> <span>{text}</span>
<span className="material-icons text-sm align-middle ml-1">arrow_forward</span> <span className="material-icons text-sm align-middle ml-1" aria-hidden="true">arrow_forward</span>
{/* Close button */} {/* Close button */}
<button <button
@@ -46,7 +46,7 @@ export function AnnouncementBar({ text, href, locale, closeLabel }: Announcement
className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors" className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors"
aria-label={closeLabel} aria-label={closeLabel}
> >
<span className="material-icons text-sm">close</span> <span className="material-icons text-sm" aria-hidden="true">close</span>
</button> </button>
</Link> </Link>
) )
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
interface NewsletterSignupProps {
labels: {
email: string;
subscribe: string;
consent: string;
unavailable: string;
};
}
export function NewsletterSignup({ labels }: NewsletterSignupProps) {
const [message, setMessage] = useState<string | null>(null);
return (
<form
className="space-y-4 max-w-md"
onSubmit={(event) => {
event.preventDefault();
setMessage(labels.unavailable);
}}
>
<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={labels.email}
type="email"
id="newsletter-email"
name="email"
autoComplete="email"
required
/>
<div className="flex items-center gap-2">
<input
className="w-4 h-4 border-2 border-black text-black focus:ring-0"
id="consent"
name="consent"
type="checkbox"
required
/>
<label className="text-xs font-bold font-display text-black" htmlFor="consent">
{labels.consent}
</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"
>
{labels.subscribe}
</button>
{message ? (
<p className="border-2 border-black bg-white px-3 py-2 font-display text-xs font-bold text-black">
{message}
</p>
) : null}
</form>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher";
interface SiteHeaderProps {
locale: string;
labels: {
home: string;
projects: string;
signals: string;
about: string;
submitProject: string;
menu: string;
closeMenu: string;
};
}
export function SiteHeader({ locale, labels }: SiteHeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const mobileMenuId = "site-mobile-menu";
const switchUrl = `/${locale === "zh" ? "en" : "zh"}`;
const submitUrl = `/${locale}/submit`;
const navLinks = [
{ href: `/${locale}`, label: labels.home },
{ href: `/${locale}/projects`, label: labels.projects },
{ href: `/${locale}/signals`, label: labels.signals },
{ href: `/${locale}/about`, label: labels.about },
];
return (
<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">
<div className="flex-shrink-0 flex items-center gap-2">
<span className="material-icons text-3xl" aria-hidden="true">
smart_toy
</span>
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight">
Agent Park
</Link>
</div>
<nav className="hidden md:flex space-x-8 items-center">
{navLinks.map((link) => (
<Link
key={link.href}
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={link.href}
>
{link.label}
</Link>
))}
</nav>
<div className="hidden md:flex items-center space-x-4">
<LocaleSwitcher currentLocale={locale} switchUrl={switchUrl} />
<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={submitUrl}
>
{labels.submitProject}
</Link>
</div>
<div className="md:hidden flex items-center">
<button
type="button"
className="text-text-light dark:text-text-dark hover:text-gray-600 focus:outline-none"
aria-label={isMenuOpen ? labels.closeMenu : labels.menu}
aria-expanded={isMenuOpen}
aria-controls={mobileMenuId}
onClick={() => setIsMenuOpen((value) => !value)}
>
<span className="material-icons" aria-hidden="true">
{isMenuOpen ? "close" : "menu"}
</span>
</button>
</div>
</div>
</div>
{isMenuOpen ? (
<div
id={mobileMenuId}
className="md:hidden border-t-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark"
>
<nav className="px-4 py-4 space-y-2">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="block border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark px-4 py-3 font-display text-sm font-bold"
onClick={() => setIsMenuOpen(false)}
>
{link.label}
</Link>
))}
<div className="flex items-center justify-between gap-3 pt-2">
<LocaleSwitcher currentLocale={locale} switchUrl={switchUrl} />
<Link
href={submitUrl}
className="bg-primary text-black border-2 border-black px-4 py-2 font-display text-sm font-bold"
onClick={() => setIsMenuOpen(false)}
>
{labels.submitProject}
</Link>
</div>
</nav>
</div>
) : null}
</header>
);
}
+3 -7
View File
@@ -1,6 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges' import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
interface ProjectCardProps { interface ProjectCardProps {
project: { project: {
@@ -54,11 +55,6 @@ export function ProjectCard({ project, locale, featured = false, translations }:
// Generate GitHub badge URLs // Generate GitHub badge URLs
const badges = project.links ? getGitHubBadgesFromLinks(project.links) : { stars: null } const badges = project.links ? getGitHubBadgesFromLinks(project.links) : { stars: null }
// Helper to get display name for tag based on locale
const getTagName = (tag: { name: string; nameEn?: string | null }) => {
return locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
}
return ( return (
<article <article
className={`bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-6 ${ className={`bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-6 ${
@@ -85,7 +81,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
key={tag.id} 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" 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"
> >
{getTagName(tag)} {getLocalizedTagName(tag, locale)}
</span> </span>
))} ))}
</div> </div>
@@ -150,7 +146,7 @@ export function SubmitProjectCard({ locale, translations }: SubmitProjectCardPro
{submitProjectDescription} {submitProjectDescription}
</p> </p>
<Link <Link
href="#" href={`/${locale}/submit`}
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" 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"
> >
{submitNow} {submitNow}
+6 -7
View File
@@ -1,5 +1,6 @@
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { MarkdownContent } from './MarkdownContent' import { MarkdownContent } from './MarkdownContent'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy' import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectDetailProps { interface ProjectDetailProps {
@@ -56,10 +57,8 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug)) const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent' const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
const categoryEn = const categoryEn =
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent' fixedTypeTag ? getLocalizedTagName(fixedTypeTag, 'en') : project.tags[0] ? getLocalizedTagName(project.tags[0], 'en') : 'AI Agent'
const displayCategory = locale === 'en' ? categoryEn : category const displayCategory = locale === 'en' ? categoryEn : category
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return ( return (
<> <>
@@ -78,17 +77,17 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
{/* Metadata */} {/* 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 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"> <div className="flex items-center gap-2">
<span className="material-icons text-base">calendar_today</span> <span className="material-icons text-base" aria-hidden="true">calendar_today</span>
<span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span> <span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span>
</div> </div>
<span className="hidden sm:inline text-gray-300">|</span> <span className="hidden sm:inline text-gray-300">|</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-icons text-base">category</span> <span className="material-icons text-base" aria-hidden="true">category</span>
<span>{displayCategory}</span> <span>{displayCategory}</span>
</div> </div>
<span className="hidden sm:inline text-gray-300">|</span> <span className="hidden sm:inline text-gray-300">|</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="material-icons text-base">code</span> <span className="material-icons text-base" aria-hidden="true">code</span>
<span>{t('openSource')}</span> <span>{t('openSource')}</span>
</div> </div>
</div> </div>
@@ -100,7 +99,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
key={tag.id} 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" 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"
> >
{getTagName(tag)} {getLocalizedTagName(tag, locale)}
</span> </span>
))} ))}
</div> </div>
+3 -3
View File
@@ -68,10 +68,10 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
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" 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"> <div className="flex items-center gap-3">
<span className="material-icons">{icon}</span> <span className="material-icons" aria-hidden="true">{icon}</span>
<span className="font-bold text-sm">{link.title || label}</span> <span className="font-bold text-sm">{link.title || label}</span>
</div> </div>
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform"> <span className="material-icons text-sm group-hover:translate-x-1 transition-transform" aria-hidden="true">
arrow_forward arrow_forward
</span> </span>
</a> </a>
@@ -110,7 +110,7 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
<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="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="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"> <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> <span className="material-icons text-2xl" aria-hidden="true">rocket_launch</span>
</div> </div>
<h3 className="font-display font-bold text-xl mb-2">{t('buildYourOwnAgent')}</h3> <h3 className="font-display font-bold text-xl mb-2">{t('buildYourOwnAgent')}</h3>
<p className="text-sm font-medium mb-6">{t('buildAgentDesc')}</p> <p className="text-sm font-medium mb-6">{t('buildAgentDesc')}</p>
+4 -5
View File
@@ -1,5 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
interface RelatedProjectsProps { interface RelatedProjectsProps {
projects: Array<{ projects: Array<{
@@ -47,7 +48,7 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group" className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
> >
{tProject('viewAll')}{' '} {tProject('viewAll')}{' '}
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span> <span className="material-icons text-sm group-hover:translate-x-1 transition-transform" aria-hidden="true">arrow_forward</span>
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -56,8 +57,6 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
const displayDescription = const displayDescription =
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
const icon = getProjectIcon(project.tags) const icon = getProjectIcon(project.tags)
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return ( return (
<Link <Link
@@ -78,14 +77,14 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
key={tag.id} key={tag.id}
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1" className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
> >
{getTagName(tag)} {getLocalizedTagName(tag, locale)}
</span> </span>
))} ))}
</div> </div>
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700"> <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"> <span className="text-xs font-display font-bold uppercase flex items-center">
{tCommon('viewDetails')}{' '} {tCommon('viewDetails')}{' '}
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform"> <span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform" aria-hidden="true">
arrow_forward arrow_forward
</span> </span>
</span> </span>
+12 -19
View File
@@ -8,6 +8,7 @@ import type {
ProjectSortOption, ProjectSortOption,
TagWithProjectCount, TagWithProjectCount,
} from '@/hooks/useProjects' } from '@/hooks/useProjects'
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
interface TagFilterPanelProps { interface TagFilterPanelProps {
locale: string locale: string
@@ -267,8 +268,7 @@ export function TagFilterPanel({
selectedTags, selectedTags,
sort, sort,
}) })
const displayName = const displayName = getLocalizedTagName(typeOption, locale)
locale === 'en' && typeOption.nameEn ? typeOption.nameEn : typeOption.name
return ( return (
<Link <Link
@@ -332,8 +332,7 @@ export function TagFilterPanel({
selectedTags, selectedTags,
sort, sort,
}) })
const displayName = const displayName = getLocalizedTagName(domainTag, locale)
locale === 'en' && domainTag.nameEn ? domainTag.nameEn : domainTag.name
return ( return (
<Link <Link
@@ -363,10 +362,9 @@ export function TagFilterPanel({
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{selectedDomains.map((domainSlug) => { {selectedDomains.map((domainSlug) => {
const matchedDomain = domainBySlug.get(domainSlug) const matchedDomain = domainBySlug.get(domainSlug)
const displayName = const displayName = matchedDomain
matchedDomain && locale === 'en' && matchedDomain.nameEn ? getLocalizedTagName(matchedDomain, locale)
? matchedDomain.nameEn : domainSlug
: matchedDomain?.name || domainSlug
const href = buildProjectsUrl({ const href = buildProjectsUrl({
locale, locale,
search, search,
@@ -427,8 +425,7 @@ export function TagFilterPanel({
selectedTags, selectedTags,
sort, sort,
}) })
const displayName = const displayName = getLocalizedTagName(productFormTag, locale)
locale === 'en' && productFormTag.nameEn ? productFormTag.nameEn : productFormTag.name
return ( return (
<Link <Link
@@ -458,10 +455,9 @@ export function TagFilterPanel({
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{selectedProductForms.map((productFormSlug) => { {selectedProductForms.map((productFormSlug) => {
const matchedProductForm = productFormBySlug.get(productFormSlug) const matchedProductForm = productFormBySlug.get(productFormSlug)
const displayName = const displayName = matchedProductForm
matchedProductForm && locale === 'en' && matchedProductForm.nameEn ? getLocalizedTagName(matchedProductForm, locale)
? matchedProductForm.nameEn : productFormSlug
: matchedProductForm?.name || productFormSlug
const href = buildProjectsUrl({ const href = buildProjectsUrl({
locale, locale,
search, search,
@@ -545,10 +541,7 @@ export function TagFilterPanel({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedTags.map((tagSlug) => { {selectedTags.map((tagSlug) => {
const matchedTag = tagBySlug.get(tagSlug) const matchedTag = tagBySlug.get(tagSlug)
const displayName = const displayName = matchedTag ? getLocalizedTagName(matchedTag, locale) : tagSlug
matchedTag && locale === 'en' && matchedTag.nameEn
? matchedTag.nameEn
: matchedTag?.name || tagSlug
const href = buildProjectsUrl({ const href = buildProjectsUrl({
locale, locale,
search, search,
@@ -638,7 +631,7 @@ export function TagFilterPanel({
selectedTags: toggleTag(tag.slug), selectedTags: toggleTag(tag.slug),
sort, sort,
}) })
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name const displayName = getLocalizedTagName(tag, locale)
return ( return (
<Link <Link
+50 -4
View File
@@ -55,6 +55,14 @@ interface SignalsApiResponse {
items: IdeaSignal[] items: IdeaSignal[]
nextCursor: string | null nextCursor: string | null
hasMore: boolean hasMore: boolean
meta: {
totalCount: number
hotCount: number
newestPublishedAt: string | null
}
facets: {
sourceCounts: Partial<Record<SignalSource, number>>
}
} }
type SortKey = 'latest' | 'hot' type SortKey = 'latest' | 'hot'
@@ -99,6 +107,7 @@ interface SourceMeta {
function formatDate(value: string, locale: string): string { function formatDate(value: string, locale: string): string {
return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', { return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}).format(new Date(value)) }).format(new Date(value))
@@ -217,6 +226,18 @@ async function fetchSignals(params: {
items: Array.isArray(data.items) ? data.items : [], items: Array.isArray(data.items) ? data.items : [],
nextCursor: typeof data.nextCursor === 'string' ? data.nextCursor : null, nextCursor: typeof data.nextCursor === 'string' ? data.nextCursor : null,
hasMore: Boolean(data.hasMore), hasMore: Boolean(data.hasMore),
meta: {
totalCount: typeof data.meta?.totalCount === 'number' ? data.meta.totalCount : 0,
hotCount: typeof data.meta?.hotCount === 'number' ? data.meta.hotCount : 0,
newestPublishedAt:
typeof data.meta?.newestPublishedAt === 'string' ? data.meta.newestPublishedAt : null,
},
facets: {
sourceCounts:
data.facets?.sourceCounts && typeof data.facets.sourceCounts === 'object'
? data.facets.sourceCounts
: {},
},
} }
} }
@@ -231,6 +252,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
const [signals, setSignals] = useState<IdeaSignal[]>([]) const [signals, setSignals] = useState<IdeaSignal[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null) const [nextCursor, setNextCursor] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [totalCount, setTotalCount] = useState(0)
const [hotCount, setHotCount] = useState(0)
const [sourceCounts, setSourceCounts] = useState<Partial<Record<SignalSource, number>>>({})
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
const [loadError, setLoadError] = useState(false) const [loadError, setLoadError] = useState(false)
@@ -270,12 +294,18 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
setSignals(page.items) setSignals(page.items)
setNextCursor(page.nextCursor) setNextCursor(page.nextCursor)
setHasMore(page.hasMore) setHasMore(page.hasMore)
setTotalCount(page.meta.totalCount)
setHotCount(page.meta.hotCount)
setSourceCounts(page.facets.sourceCounts)
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
console.error('[Signals] failed to load first page:', error) console.error('[Signals] failed to load first page:', error)
setSignals([]) setSignals([])
setNextCursor(null) setNextCursor(null)
setHasMore(false) setHasMore(false)
setTotalCount(0)
setHotCount(0)
setSourceCounts({})
setLoadError(true) setLoadError(true)
} }
} finally { } finally {
@@ -293,6 +323,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
} }
}, [debouncedSearch, locale, sort, source]) }, [debouncedSearch, locale, sort, source])
useEffect(() => {
if (source !== 'all' && !isLoading && (sourceCounts[source] || 0) === 0) {
setSource('all')
}
}, [isLoading, source, sourceCounts])
useEffect(() => { useEffect(() => {
if (!isSortMenuOpen) { if (!isSortMenuOpen) {
return return
@@ -342,6 +378,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
setSignals((previous) => [...previous, ...page.items]) setSignals((previous) => [...previous, ...page.items])
setNextCursor(page.nextCursor) setNextCursor(page.nextCursor)
setHasMore(page.hasMore) setHasMore(page.hasMore)
setTotalCount(page.meta.totalCount)
setHotCount(page.meta.hotCount)
setSourceCounts(page.facets.sourceCounts)
} catch (error) { } catch (error) {
console.error('[Signals] failed to load more:', error) console.error('[Signals] failed to load more:', error)
setLoadError(true) setLoadError(true)
@@ -384,7 +423,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
} }
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
const hotCount = signals.filter((item) => item.isHot).length const visibleSourceOptions = sourceOptions.filter((option) => {
if (option === 'all') {
return true
}
return (sourceCounts[option] || 0) > 0 || option === source
})
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -457,11 +501,11 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end"> <div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
<span <span
title={translations.totalCountHint} title={translations.totalCountHint}
aria-label={`${translations.totalCountHint}: ${signals.length}`} aria-label={`${translations.totalCountHint}: ${totalCount}`}
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black" className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
> >
<List className="h-3 w-3" aria-hidden="true" /> <List className="h-3 w-3" aria-hidden="true" />
{signals.length} {totalCount}
</span> </span>
<span <span
title={translations.hotCountHint} title={translations.hotCountHint}
@@ -476,10 +520,11 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
<div className="mt-2 min-w-0 overflow-x-auto pb-1"> <div className="mt-2 min-w-0 overflow-x-auto pb-1">
<div className="flex min-w-max gap-1.5"> <div className="flex min-w-max gap-1.5">
{sourceOptions.map((option) => { {visibleSourceOptions.map((option) => {
const active = source === option const active = source === option
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option] const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
const SourceIcon = meta.icon const SourceIcon = meta.icon
const count = option === 'all' ? totalCount : sourceCounts[option] || 0
return ( return (
<button <button
@@ -492,6 +537,7 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" /> <SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
{sourceLabels[option]} {sourceLabels[option]}
{count > 0 ? <span className="opacity-70">{count}</span> : null}
</span> </span>
</button> </button>
) )
+38
View File
@@ -0,0 +1,38 @@
interface StaticInfoPageProps {
eyebrow: string;
title: string;
description: string;
sections: Array<{
title: string;
body: string;
}>;
}
export function StaticInfoPage({ eyebrow, title, description, sections }: StaticInfoPageProps) {
return (
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-14 md:py-20">
<section className="neo-card bg-white p-6 md:p-8 dark:bg-surface-dark">
<p className="inline-flex border-2 border-black bg-primary px-3 py-1 font-display text-xs font-bold uppercase text-black">
{eyebrow}
</p>
<h1 className="mt-5 font-display text-4xl md:text-5xl font-bold tracking-tight">
{title}
</h1>
<p className="mt-4 max-w-3xl text-gray-700 dark:text-gray-300">
{description}
</p>
</section>
<section className="mt-8 grid gap-5">
{sections.map((section) => (
<article key={section.title} className="neo-card p-6 md:p-7">
<h2 className="font-display text-xl font-bold">{section.title}</h2>
<p className="mt-3 leading-relaxed text-gray-700 dark:text-gray-300">
{section.body}
</p>
</article>
))}
</section>
</main>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
interface ProjectSubmissionFormProps {
locale: string;
labels: {
url: string;
projectName: string;
description: string;
submitterName: string;
submitterEmail: string;
submit: string;
submitting: string;
success: string;
duplicate: string;
invalid: string;
failed: string;
};
}
export function ProjectSubmissionForm({ locale, labels }: ProjectSubmissionFormProps) {
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
setStatus("submitting");
setMessage(null);
try {
const response = await fetch("/api/project-submissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
url: formData.get("url"),
projectName: formData.get("projectName"),
description: formData.get("description"),
submitterName: formData.get("submitterName"),
submitterEmail: formData.get("submitterEmail"),
locale,
}),
});
const body = await response.json();
if (response.ok) {
setStatus("success");
setMessage(labels.success);
form.reset();
return;
}
setStatus("error");
if (response.status === 409 || body?.error === "duplicate_submission") {
setMessage(labels.duplicate);
} else if (response.status === 400 || body?.error === "invalid_submission") {
setMessage(labels.invalid);
} else {
setMessage(labels.failed);
}
} catch {
setStatus("error");
setMessage(labels.failed);
}
}
return (
<form className="neo-card p-6 md:p-8 space-y-5" onSubmit={handleSubmit}>
<label className="block">
<span className="font-display text-xs font-bold uppercase">{labels.url}</span>
<input
name="url"
type="url"
required
maxLength={2000}
placeholder="https://github.com/org/project"
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
/>
</label>
<label className="block">
<span className="font-display text-xs font-bold uppercase">{labels.projectName}</span>
<input
name="projectName"
maxLength={200}
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
/>
</label>
<label className="block">
<span className="font-display text-xs font-bold uppercase">{labels.description}</span>
<textarea
name="description"
rows={5}
maxLength={1000}
className="mt-2 w-full resize-y border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
/>
</label>
<div className="grid gap-5 md:grid-cols-2">
<label className="block">
<span className="font-display text-xs font-bold uppercase">{labels.submitterName}</span>
<input
name="submitterName"
maxLength={120}
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
/>
</label>
<label className="block">
<span className="font-display text-xs font-bold uppercase">{labels.submitterEmail}</span>
<input
name="submitterEmail"
type="email"
maxLength={254}
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
/>
</label>
</div>
<button
type="submit"
disabled={status === "submitting"}
className="neo-btn bg-primary px-6 py-3 text-sm text-black disabled:opacity-60"
>
{status === "submitting" ? labels.submitting : labels.submit}
</button>
{message ? (
<p
className={`border-2 px-4 py-3 font-display text-sm font-bold ${
status === "success"
? "border-emerald-700 bg-emerald-50 text-emerald-900"
: "border-red-700 bg-red-50 text-red-900"
}`}
role={status === "success" ? "status" : "alert"}
>
{message}
</p>
) : null}
</form>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildHomePageData } from "./useHome";
const { projectCountMock, projectFindManyMock } = vi.hoisted(() => ({
projectCountMock: vi.fn(),
projectFindManyMock: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
project: {
count: projectCountMock,
findMany: projectFindManyMock,
},
},
}));
vi.mock("@/hooks/useProjects", () => ({
getTopTags: vi.fn(async () => []),
}));
describe("buildHomePageData", () => {
beforeEach(() => {
projectCountMock.mockReset();
projectFindManyMock.mockReset();
projectCountMock.mockResolvedValue(0);
projectFindManyMock.mockResolvedValue([]);
});
it("counts total projects using only ACTIVE projects", async () => {
await buildHomePageData();
expect(projectCountMock).toHaveBeenCalledWith({
where: {
status: "ACTIVE",
},
});
});
});
+9 -3
View File
@@ -155,7 +155,7 @@ function getLatestProjectsByWindow(
return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit); return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit);
} }
async function buildHomePageData(): Promise<HomePageData> { export async function buildHomePageData(): Promise<HomePageData> {
const now = Date.now(); const now = Date.now();
const last24Hours = new Date(now - ONE_DAY_MS); const last24Hours = new Date(now - ONE_DAY_MS);
const last7Days = new Date(now - ONE_DAY_MS * 7); const last7Days = new Date(now - ONE_DAY_MS * 7);
@@ -171,7 +171,13 @@ async function buildHomePageData(): Promise<HomePageData> {
topStars, topStars,
topTags, topTags,
] = await Promise.all([ ] = await Promise.all([
safeQuery("countTotalProjects", 0, () => prisma.project.count()), safeQuery("countTotalProjects", 0, () =>
prisma.project.count({
where: {
status: "ACTIVE",
},
})
),
safeQuery("countNewProjects30d", 0, () => safeQuery("countNewProjects30d", 0, () =>
prisma.project.count({ prisma.project.count({
where: { where: {
@@ -204,7 +210,7 @@ async function buildHomePageData(): Promise<HomePageData> {
), ),
getLatestProjects(latestProjectsLimit), getLatestProjects(latestProjectsLimit),
getTopStarsProjects(DEFAULT_RANKING_LIMIT), getTopStarsProjects(DEFAULT_RANKING_LIMIT),
getTopTags(DEFAULT_TOP_TAG_LIMIT), safeQuery("getTopTags", [], () => getTopTags(DEFAULT_TOP_TAG_LIMIT)),
]); ]);
const latest24h = getLatestProjectsByWindow(latestProjects, last24Hours, DEFAULT_RANKING_LIMIT); const latest24h = getLatestProjectsByWindow(latestProjects, last24Hours, DEFAULT_RANKING_LIMIT);
+39
View File
@@ -0,0 +1,39 @@
const ENGLISH_TAG_FALLBACKS: Record<string, string> = {
"ai-agents": "AI Agents",
"code-dev": "Developer Tools & Coding",
python: "Python",
"workflow-automation": "Workflow Automation",
"automation-workflow": "Automation, Workflow & RPA",
"api-integration": "Protocol, API & Integration",
cli: "CLI",
"model-context-protocol": "Model Context Protocol",
"agent-framework": "Agent Framework",
typescript: "TypeScript",
"multi-agent-system": "Multi-Agent System",
"大语言模型": "Large Language Models",
"桌面应用": "Desktop Apps",
"知识库": "Knowledge Base",
"浏览器自动化": "Browser Automation",
"多模态": "Multimodal",
"安全-隐私": "Security & Privacy",
};
export function getLocalizedTagName(
tag: { name: string; nameEn?: string | null; slug?: string | null },
locale: string
): string {
if (locale !== "en") {
return tag.name;
}
if (tag.nameEn && tag.nameEn.trim().length > 0) {
return tag.nameEn;
}
const slugFallback = tag.slug ? ENGLISH_TAG_FALLBACKS[tag.slug] : undefined;
if (slugFallback) {
return slugFallback;
}
return ENGLISH_TAG_FALLBACKS[tag.name] || tag.name;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
ProjectSubmissionInputSchema,
normalizeSubmissionUrl,
} from "./project-submissions";
describe("normalizeSubmissionUrl", () => {
it("lowercases protocol and host, removes hash and tracking query params", () => {
expect(
normalizeSubmissionUrl(
"HTTPS://GitHub.com/Owner/Repo/?utm_source=x&gclid=abc&ref=agentpark#readme"
)
).toBe("https://github.com/Owner/Repo?ref=agentpark");
});
it("removes a trailing slash while preserving meaningful query params", () => {
expect(normalizeSubmissionUrl("https://example.com/path/?b=2&a=1")).toBe(
"https://example.com/path?a=1&b=2"
);
});
});
describe("ProjectSubmissionInputSchema", () => {
it("accepts minimal valid submissions", () => {
const parsed = ProjectSubmissionInputSchema.parse({
url: "https://github.com/example/agent",
});
expect(parsed).toMatchObject({
url: "https://github.com/example/agent",
locale: "zh",
});
});
it("rejects non-http URLs", () => {
expect(() =>
ProjectSubmissionInputSchema.parse({
url: "ftp://example.com/project",
})
).toThrow();
});
it("normalizes blank optional strings to undefined", () => {
const parsed = ProjectSubmissionInputSchema.parse({
url: "https://example.com/project",
projectName: " ",
submitterEmail: "",
});
expect(parsed.projectName).toBeUndefined();
expect(parsed.submitterEmail).toBeUndefined();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
const TRACKING_QUERY_PREFIXES = ["utm_"];
const TRACKING_QUERY_KEYS = new Set(["fbclid", "gclid", "mc_cid", "mc_eid"]);
function optionalTrimmedString(max: number) {
return z.preprocess(
(value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
},
z.string().max(max).optional()
);
}
function optionalEmail() {
return z.preprocess(
(value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
},
z.string().email().max(254).optional()
);
}
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export function normalizeSubmissionUrl(value: string): string {
const parsed = new URL(value.trim());
parsed.protocol = parsed.protocol.toLowerCase();
parsed.hostname = parsed.hostname.toLowerCase();
parsed.hash = "";
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
parsed.pathname = parsed.pathname.slice(0, -1);
}
for (const key of Array.from(parsed.searchParams.keys())) {
const normalizedKey = key.toLowerCase();
if (
TRACKING_QUERY_KEYS.has(normalizedKey) ||
TRACKING_QUERY_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))
) {
parsed.searchParams.delete(key);
}
}
parsed.searchParams.sort();
return parsed.toString().replace(/\/$/, "");
}
export const ProjectSubmissionInputSchema = z.object({
url: z
.string()
.trim()
.min(1, "URL is required")
.max(2000, "URL is too long")
.refine(isHttpUrl, "URL must use http or https protocol"),
projectName: optionalTrimmedString(200),
description: optionalTrimmedString(1000),
submitterName: optionalTrimmedString(120),
submitterEmail: optionalEmail(),
locale: z.enum(["zh", "en"]).default("zh"),
});
export type ProjectSubmissionInput = z.infer<typeof ProjectSubmissionInputSchema>;
+67 -2
View File
@@ -123,6 +123,8 @@
"paginationSummary": "Page {current} / {totalPages} · {total} total", "paginationSummary": "Page {current} / {totalPages} · {total} total",
"showFilterPanel": "Show Filters", "showFilterPanel": "Show Filters",
"hideFilterPanel": "Hide Filters", "hideFilterPanel": "Hide Filters",
"aiSearchUnavailableFallback": "AI search is temporarily unavailable, so regular search results are shown.",
"aiSearchEmptyFallback": "No semantic results yet, so regular search results are shown.",
"relatedProjects": "Related Projects", "relatedProjects": "Related Projects",
"viewAll": "View All", "viewAll": "View All",
"addedOn": "Added {date}", "addedOn": "Added {date}",
@@ -178,7 +180,25 @@
"principleUsefulDescription": "The structure is designed to answer practical questions: when to use it, why, and with what.", "principleUsefulDescription": "The structure is designed to answer practical questions: when to use it, why, and with what.",
"closingTitle": "Help Improve This Directory", "closingTitle": "Help Improve This Directory",
"closingDescription": "If you are building an AI product, or found a project worth tracking, submit it. We will keep improving data quality and browsing experience.", "closingDescription": "If you are building an AI product, or found a project worth tracking, submit it. We will keep improving data quality and browsing experience.",
"closingCta": "Explore Project List" "closingCta": "Submit Project"
},
"submit": {
"metaTitle": "Submit Project - Agent Park",
"metaDescription": "Recommend an AI Agent project for Agent Park.",
"eyebrow": "Project Recommendation",
"title": "Submit an AI Agent Project",
"description": "Recommend an AI project you are building or recently found. Submissions enter a review queue and are not published directly.",
"url": "Project URL",
"projectName": "Project name (optional)",
"projectDescription": "Why it should be listed or what it does (optional)",
"submitterName": "Your name (optional)",
"submitterEmail": "Contact email (optional)",
"submit": "Submit Project",
"submitting": "Submitting...",
"success": "Submission received. It will enter the review queue.",
"duplicate": "This URL has already been submitted. Thanks for the extra signal.",
"invalid": "Please check the project URL and form fields.",
"failed": "Submission failed. Please try again later."
}, },
"signals": { "signals": {
"metaTitle": "Frontier Signals - Agent Park", "metaTitle": "Frontier Signals - Agent Park",
@@ -229,7 +249,9 @@
"projects": "Projects", "projects": "Projects",
"signals": "Signals", "signals": "Signals",
"about": "About", "about": "About",
"submitProject": "SUBMIT PROJECT" "submitProject": "SUBMIT PROJECT",
"menu": "Open menu",
"closeMenu": "Close menu"
}, },
"notFound": { "notFound": {
"title": "Page Not Found", "title": "Page Not Found",
@@ -245,6 +267,7 @@
"email": "E-mail", "email": "E-mail",
"subscribe": "SUBSCRIBE", "subscribe": "SUBSCRIBE",
"subscribeConsent": "Subscribe to Agent Park updates", "subscribeConsent": "Subscribe to Agent Park updates",
"newsletterUnavailable": "Newsletter signup is being connected. Your email is not saved yet.",
"footerDesc": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.", "footerDesc": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
"resources": "Resources", "resources": "Resources",
"resourceNewsletter": "Newsletter", "resourceNewsletter": "Newsletter",
@@ -257,5 +280,47 @@
"closeAnnouncement": "Close announcement", "closeAnnouncement": "Close announcement",
"copyright": "© 2025 Agent Park. All rights reserved.", "copyright": "© 2025 Agent Park. All rights reserved.",
"designedFor": "DESIGNED FOR AI BUILDERS" "designedFor": "DESIGNED FOR AI BUILDERS"
},
"staticPages": {
"newsletter": {
"title": "Newsletter",
"description": "Agent Park will package new projects, trend signals, and product updates into concise digests.",
"sections": [
{ "title": "Current status", "body": "Newsletter delivery is still being connected. The form shows a clear status and does not silently save emails." },
{ "title": "Planned content", "body": "Future issues will prioritize new projects, top tag movement, frontier signal summaries, and important product updates." }
]
},
"updates": {
"title": "Updates",
"description": "Important Agent Park product, data, and experience updates.",
"sections": [
{ "title": "April 2026", "body": "Improved production search fallback, submission entry points, mobile navigation, frontier signals, and SEO foundations." },
{ "title": "Next", "body": "This log will gradually include more detailed release notes and data workflow changes." }
]
},
"docs": {
"title": "Documentation",
"description": "How Agent Park collects, structures, and presents AI Agent projects.",
"sections": [
{ "title": "Project intake", "body": "Projects are organized from public sources such as websites, repositories, and papers, then reviewed or structurally imported." },
{ "title": "Data model", "body": "Each listing prioritizes names, descriptions, links, tags, GitHub signals, and bilingual context." }
]
},
"privacy": {
"title": "Privacy Policy",
"description": "How Agent Park currently handles information that users submit voluntarily.",
"sections": [
{ "title": "Submission data", "body": "When you submit a project, we store the project URL and any contact details you choose to provide for deduplication, review, and necessary follow-up." },
{ "title": "Third-party services", "body": "The site may use deployment analytics and link to external sources. We do not sell personal information." }
]
},
"terms": {
"title": "Terms of Service",
"description": "Basic rules for using Agent Park.",
"sections": [
{ "title": "Content accuracy", "body": "We try to provide accurate project information, but project status, licenses, and features may change. Always verify against original sources." },
{ "title": "Submission responsibility", "body": "Only submit public, lawful, AI Agent related project links. Do not submit sensitive or rights-infringing content." }
]
}
} }
} }
+67 -2
View File
@@ -123,6 +123,8 @@
"paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条", "paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条",
"showFilterPanel": "展开筛选器", "showFilterPanel": "展开筛选器",
"hideFilterPanel": "收起筛选器", "hideFilterPanel": "收起筛选器",
"aiSearchUnavailableFallback": "AI 搜索暂不可用,已显示普通搜索结果。",
"aiSearchEmptyFallback": "暂无语义搜索结果,已显示普通搜索结果。",
"relatedProjects": "相关项目", "relatedProjects": "相关项目",
"viewAll": "查看全部", "viewAll": "查看全部",
"addedOn": "收录于 {date}", "addedOn": "收录于 {date}",
@@ -178,7 +180,25 @@
"principleUsefulDescription": "内容组织优先服务“怎么用、何时用、和谁一起用”的实际决策。", "principleUsefulDescription": "内容组织优先服务“怎么用、何时用、和谁一起用”的实际决策。",
"closingTitle": "一起把这个目录做得更好", "closingTitle": "一起把这个目录做得更好",
"closingDescription": "如果你在做 AI 项目,或者发现了值得收录的工具,欢迎提交给我们。我们会持续优化数据质量与浏览体验。", "closingDescription": "如果你在做 AI 项目,或者发现了值得收录的工具,欢迎提交给我们。我们会持续优化数据质量与浏览体验。",
"closingCta": "去项目列表看看" "closingCta": "提交项目"
},
"submit": {
"metaTitle": "提交项目 - Agent Park",
"metaDescription": "向 Agent Park 推荐值得收录的 AI Agent 项目。",
"eyebrow": "项目推荐",
"title": "提交一个 AI Agent 项目",
"description": "推荐你正在构建或最近发现的 AI 项目。提交后会进入待审队列,不会直接发布到正式列表。",
"url": "项目链接",
"projectName": "项目名称(可选)",
"projectDescription": "推荐理由或项目简介(可选)",
"submitterName": "你的名字(可选)",
"submitterEmail": "联系邮箱(可选)",
"submit": "提交项目",
"submitting": "提交中...",
"success": "已收到提交,项目会进入待审队列。",
"duplicate": "这个链接已经提交过,感谢补充。",
"invalid": "请检查项目链接和表单内容。",
"failed": "提交失败,请稍后重试。"
}, },
"signals": { "signals": {
"metaTitle": "前沿信号 - Agent Park", "metaTitle": "前沿信号 - Agent Park",
@@ -229,7 +249,9 @@
"projects": "项目列表", "projects": "项目列表",
"signals": "前沿信号", "signals": "前沿信号",
"about": "关于", "about": "关于",
"submitProject": "提交项目" "submitProject": "提交项目",
"menu": "打开菜单",
"closeMenu": "关闭菜单"
}, },
"notFound": { "notFound": {
"title": "页面未找到", "title": "页面未找到",
@@ -245,6 +267,7 @@
"email": "电子邮箱", "email": "电子邮箱",
"subscribe": "订阅", "subscribe": "订阅",
"subscribeConsent": "订阅 Agent Park 更新", "subscribeConsent": "订阅 Agent Park 更新",
"newsletterUnavailable": "订阅功能正在接入中,当前不会保存邮箱。",
"footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。", "footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
"resources": "资源", "resources": "资源",
"resourceNewsletter": "新闻订阅", "resourceNewsletter": "新闻订阅",
@@ -257,5 +280,47 @@
"closeAnnouncement": "关闭公告", "closeAnnouncement": "关闭公告",
"copyright": "© 2025 Agent Park. 保留所有权利。", "copyright": "© 2025 Agent Park. 保留所有权利。",
"designedFor": "专为 AI 构建者设计" "designedFor": "专为 AI 构建者设计"
},
"staticPages": {
"newsletter": {
"title": "新闻订阅",
"description": "Agent Park 会把新增项目、趋势信号和站点更新整理成适合快速浏览的摘要。",
"sections": [
{ "title": "当前状态", "body": "订阅系统仍在接入中。页面表单会给出明确状态,不会静默保存邮箱。" },
{ "title": "计划内容", "body": "后续会优先发送新增项目、热门标签变化、前沿信号摘要和重要产品更新。" }
]
},
"updates": {
"title": "更新日志",
"description": "记录 Agent Park 的重要产品、数据和体验更新。",
"sections": [
{ "title": "2026-04", "body": "优化正式站搜索降级、提交入口、移动端导航、前沿信号和 SEO 基础设施。" },
{ "title": "后续", "body": "更新日志会逐步补充更细的版本说明和数据流程变化。" }
]
},
"docs": {
"title": "文档",
"description": "说明 Agent Park 如何收录、整理和展示 AI Agent 项目。",
"sections": [
{ "title": "项目收录", "body": "项目会基于官网、仓库、论文等公开来源整理,并进入待审或结构化入库流程。" },
{ "title": "数据结构", "body": "每个项目优先保留名称、描述、链接、标签、GitHub 数据和双语上下文。" }
]
},
"privacy": {
"title": "隐私政策",
"description": "说明 Agent Park 当前如何处理用户主动提交的信息。",
"sections": [
{ "title": "提交数据", "body": "当你提交项目时,我们会保存项目链接和你自愿填写的联系信息,用于去重、审核和必要沟通。" },
{ "title": "第三方服务", "body": "站点可能使用部署平台分析能力和外部链接跳转。我们不会出售个人信息。" }
]
},
"terms": {
"title": "服务条款",
"description": "使用 Agent Park 时需要了解的基本规则。",
"sections": [
{ "title": "内容准确性", "body": "站点尽力提供准确的项目信息,但项目状态、许可证和功能可能随时变化,请以原始来源为准。" },
{ "title": "提交责任", "body": "请只提交公开、合法、与 AI Agent 相关的项目链接,不要提交敏感或侵犯权益的内容。" }
]
}
} }
} }