feat: improve production site trust flows
This commit is contained in:
@@ -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");
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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 相关的项目链接,不要提交敏感或侵犯权益的内容。" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user