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")
|
||||
}
|
||||
|
||||
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 {
|
||||
WEBSITE
|
||||
GITHUB
|
||||
@@ -149,6 +168,13 @@ enum TaskStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ProjectSubmissionStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
IMPORTED
|
||||
}
|
||||
|
||||
enum TagCategory {
|
||||
FIXED_PROJECT_TYPE
|
||||
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">
|
||||
<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')}
|
||||
</p>
|
||||
<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')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
href={`/${locale}/submit`}
|
||||
className="neo-btn bg-white dark:bg-surface-dark px-6 py-3 text-sm"
|
||||
>
|
||||
{t('ctaSubmit')}
|
||||
@@ -135,7 +135,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{capabilityCards.map((card) => (
|
||||
<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>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{card.description}</p>
|
||||
</article>
|
||||
@@ -151,7 +151,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{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">
|
||||
<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>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">{step.description}</p>
|
||||
</article>
|
||||
@@ -165,7 +165,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{principles.map((principle) => (
|
||||
<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>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{principle.description}</p>
|
||||
</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">
|
||||
<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>
|
||||
<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')}
|
||||
</Link>
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+28
-101
@@ -2,8 +2,9 @@ import { notFound } from "next/navigation"
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server'
|
||||
import Link from "next/link"
|
||||
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
|
||||
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
|
||||
import { NewsletterSignup } from "@/components/layout/NewsletterSignup"
|
||||
import { SiteHeader } from "@/components/layout/SiteHeader"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const locales = ['zh', 'en']
|
||||
@@ -54,69 +55,18 @@ export default async function LocaleLayout({
|
||||
closeLabel={t('closeAnnouncement')}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<span className="material-icons text-3xl">smart_toy</span>
|
||||
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight">
|
||||
Agent Park
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 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'}`}
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
labels={{
|
||||
home: tNav('home'),
|
||||
projects: tNav('projects'),
|
||||
signals: tNav('signals'),
|
||||
about: tNav('about'),
|
||||
submitProject: tNav('submitProject'),
|
||||
menu: tNav('menu'),
|
||||
closeMenu: tNav('closeMenu'),
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -128,36 +78,21 @@ export default async function LocaleLayout({
|
||||
<p className="font-sans text-black mb-8 max-w-md">
|
||||
{t('stayUpdatedDesc')}
|
||||
</p>
|
||||
<form className="space-y-4 max-w-md">
|
||||
<input
|
||||
className="w-full bg-background-light border-2 border-black p-3 font-display text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
placeholder={t('email')}
|
||||
type="email"
|
||||
id="newsletter-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
<NewsletterSignup
|
||||
labels={{
|
||||
email: t('email'),
|
||||
subscribe: t('subscribe'),
|
||||
consent: t('subscribeConsent'),
|
||||
unavailable: t('newsletterUnavailable'),
|
||||
}}
|
||||
/>
|
||||
<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 className="bg-background-light dark:bg-background-dark p-12 md:p-20 relative overflow-hidden flex items-center justify-center">
|
||||
<div className="relative w-64 h-64">
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-white dark:bg-surface-dark transform rotate-3"></div>
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-primary dark:bg-primary transform -rotate-3 translate-x-4 translate-y-4 opacity-80"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<span className="material-icons text-8xl">rocket_launch</span>
|
||||
<span className="material-icons text-8xl" aria-hidden="true">rocket_launch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,10 +102,10 @@ export default async function LocaleLayout({
|
||||
{/* Footer */}
|
||||
<footer className="bg-background-light dark:bg-background-dark border-t-2 border-black dark:border-gray-700 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="col-span-1 md:col-span-1">
|
||||
<Link className="flex items-center gap-2 mb-4" href={`/${locale}`}>
|
||||
<span className="material-icons text-2xl">smart_toy</span>
|
||||
<span className="material-icons text-2xl" aria-hidden="true">smart_toy</span>
|
||||
<span className="font-display font-bold text-lg tracking-tight">Agent Park</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -180,26 +115,18 @@ export default async function LocaleLayout({
|
||||
<div>
|
||||
<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">
|
||||
<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="#">{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}/newsletter`}>{t('resourceNewsletter')}</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={`/${locale}/docs`}>{t('resourceDocumentation')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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="#">{t('termsOfService')}</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={`/${locale}/terms`}>{t('termsOfService')}</Link></li>
|
||||
</ul>
|
||||
</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 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>
|
||||
|
||||
@@ -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 (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')
|
||||
router.push(`/${locale}/projects?${params.toString()}`)
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@ type AISearchPagination = {
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type AIFallbackReason = 'error' | 'empty' | null
|
||||
|
||||
type ProjectsPagination = {
|
||||
total: number
|
||||
page: number
|
||||
@@ -69,6 +71,8 @@ interface ProjectsResultsClientProps {
|
||||
noProjects: string
|
||||
noResults: string
|
||||
searching: string
|
||||
aiSearchUnavailableFallback: string
|
||||
aiSearchEmptyFallback: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +172,15 @@ export function ProjectsResultsClient({
|
||||
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
|
||||
const [loadingAI, setLoadingAI] = useState(false)
|
||||
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 [aiSort, setAiSort] = useState<ProjectSortOption>(sort)
|
||||
const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit)
|
||||
@@ -260,6 +273,9 @@ export function ProjectsResultsClient({
|
||||
setTraditionalLimit(nextLimit)
|
||||
setTraditionalError(null)
|
||||
setLoadingTraditional(false)
|
||||
setAiFallbackReason(null)
|
||||
setAiFallbackResults([])
|
||||
setLoadingAIFallback(false)
|
||||
}, [
|
||||
isAI,
|
||||
page,
|
||||
@@ -379,6 +395,92 @@ export function ProjectsResultsClient({
|
||||
[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(
|
||||
(nextPage: number) => {
|
||||
const safePage = Math.max(1, nextPage)
|
||||
@@ -415,6 +517,8 @@ export function ProjectsResultsClient({
|
||||
|
||||
setLoadingAI(true)
|
||||
setAiError(null)
|
||||
setAiFallbackReason(null)
|
||||
setAiFallbackResults([])
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search/ai', {
|
||||
@@ -477,11 +581,23 @@ export function ProjectsResultsClient({
|
||||
setAiCurrentPage(safePage)
|
||||
replaceProjectsUrl(safePage, aiSort, true, aiLimit)
|
||||
}
|
||||
|
||||
if (normalizedResults.length === 0) {
|
||||
void fetchAIFallbackResults(1, aiSort, aiLimit, 'empty')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
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([])
|
||||
setAiPagination((prev) => ({
|
||||
...prev,
|
||||
@@ -506,6 +622,7 @@ export function ProjectsResultsClient({
|
||||
aiCurrentPage,
|
||||
aiLimit,
|
||||
aiSort,
|
||||
fetchAIFallbackResults,
|
||||
isAI,
|
||||
locale,
|
||||
replaceProjectsUrl,
|
||||
@@ -518,7 +635,8 @@ export function ProjectsResultsClient({
|
||||
selectedTagsKey,
|
||||
])
|
||||
|
||||
const loading = isAI ? loadingAI : loadingTraditional
|
||||
const isUsingAIFallback = isAI && aiFallbackReason !== null
|
||||
const loading = isAI ? loadingAI || loadingAIFallback : loadingTraditional
|
||||
const activeSort = isAI ? aiSort : traditionalSort
|
||||
const activePage = isAI ? aiCurrentPage : traditionalCurrentPage
|
||||
const activeLimit = isAI ? aiLimit : traditionalLimit
|
||||
@@ -528,16 +646,38 @@ export function ProjectsResultsClient({
|
||||
const nextStarSort: ProjectSortOption = currentStarSort === 'stars_desc' ? 'stars_asc' : 'stars_desc'
|
||||
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 projectsCountText =
|
||||
locale === 'en' ? `${visibleProjectsLabel} projects` : `${visibleProjectsLabel} 个项目`
|
||||
const currentPage = isAI ? aiPagination.page || activePage : traditionalPagination.page || activePage
|
||||
const activeTotalPages = isAI ? aiPagination.totalPages : traditionalPagination.totalPages
|
||||
const currentPage = isAI
|
||||
? 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 canGoNext = currentPage < activeTotalPages
|
||||
const displayProjects = isAI ? [] : traditionalResults
|
||||
const displayProjects = isAI
|
||||
? isUsingAIFallback
|
||||
? aiFallbackResults
|
||||
: []
|
||||
: traditionalResults
|
||||
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 currentPageForSummary = totalPagesForSummary === 0 ? 0 : Math.min(currentPage, totalPagesForSummary)
|
||||
@@ -607,11 +747,36 @@ export function ProjectsResultsClient({
|
||||
</div>
|
||||
) : 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 ? (
|
||||
loadingAI ? (
|
||||
loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.searching}</p>
|
||||
</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
|
||||
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"
|
||||
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
|
||||
</span>
|
||||
{tProject('backToProjects')}
|
||||
|
||||
@@ -198,6 +198,8 @@ export default async function ProjectsPage({
|
||||
noProjects: tCommon('noProjects'),
|
||||
noResults: tCommon('noResults'),
|
||||
searching: tCommon('searching'),
|
||||
aiSearchUnavailableFallback: tProject('aiSearchUnavailableFallback'),
|
||||
aiSearchEmptyFallback: tProject('aiSearchEmptyFallback'),
|
||||
}}
|
||||
/>
|
||||
</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 { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects'
|
||||
|
||||
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')
|
||||
}
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK
|
||||
|
||||
// n8n 返回的搜索结果 Schema(统一格式)
|
||||
const N8NSearchResponseSchema = z.object({
|
||||
@@ -55,6 +51,17 @@ export async function POST(request: Request) {
|
||||
|
||||
// 验证查询参数
|
||||
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 limit = Math.max(1, validatedQuery.limit)
|
||||
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) {
|
||||
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 返回数据
|
||||
@@ -217,8 +231,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
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 orderByWithHot: Prisma.SignalOrderByWithRelationInput[] =
|
||||
@@ -282,6 +286,30 @@ export async function GET(request: NextRequest) {
|
||||
? [{ engagement: '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[]
|
||||
|
||||
if (hasHotColumns) {
|
||||
@@ -331,6 +359,19 @@ export async function GET(request: NextRequest) {
|
||||
items,
|
||||
nextCursor,
|
||||
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) {
|
||||
if (error instanceof ZodError) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
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 {
|
||||
rules: [
|
||||
|
||||
+39
-42
@@ -1,55 +1,52 @@
|
||||
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> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.ai'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.fun'
|
||||
|
||||
// 获取所有项目
|
||||
const { projects } = await getProjects({ limit: 1000 })
|
||||
let projects: Array<{ slug: string; updatedAt: Date }> = []
|
||||
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 staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/zh`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
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 now = new Date()
|
||||
const staticPages: MetadataRoute.Sitemap = locales.flatMap((locale) =>
|
||||
staticPaths.map((path) => ({
|
||||
url: `${baseUrl}/${locale}${path}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: path === '' ? 1 : path === '/projects' ? 0.9 : 0.7,
|
||||
}))
|
||||
)
|
||||
|
||||
// 项目详情页(中英文)
|
||||
const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) => [
|
||||
{
|
||||
url: `${baseUrl}/zh/projects/${project.slug}`,
|
||||
const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) =>
|
||||
locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/projects/${project.slug}`,
|
||||
lastModified: project.updatedAt,
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/en/projects/${project.slug}`,
|
||||
lastModified: project.updatedAt,
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
])
|
||||
}))
|
||||
)
|
||||
|
||||
return [...staticPages, ...projectPages]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import type { HomeProjectSummary } from '@/hooks/useHome'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface HomeRecentTimelineProps {
|
||||
locale: string
|
||||
@@ -66,7 +67,7 @@ export function HomeRecentTimeline({
|
||||
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"
|
||||
>
|
||||
{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface HomeTagInsightsProps {
|
||||
locale: string
|
||||
@@ -48,7 +49,7 @@ export function HomeTagInsights({
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{formatNumber(tag.projectCount, locale)}
|
||||
</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"
|
||||
>
|
||||
<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 */}
|
||||
<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"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<span className="material-icons text-sm">close</span>
|
||||
<span className="material-icons text-sm" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</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 Image from 'next/image'
|
||||
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: {
|
||||
@@ -54,11 +55,6 @@ export function ProjectCard({ project, locale, featured = false, translations }:
|
||||
// Generate GitHub badge URLs
|
||||
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 (
|
||||
<article
|
||||
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}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,7 +146,7 @@ export function SubmitProjectCard({ locale, translations }: SubmitProjectCardPro
|
||||
{submitProjectDescription}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
{submitNow}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
@@ -56,10 +57,8 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
|
||||
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
|
||||
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 getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,17 +77,17 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">calendar_today</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">calendar_today</span>
|
||||
<span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">category</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">category</span>
|
||||
<span>{displayCategory}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">code</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">code</span>
|
||||
<span>{t('openSource')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +99,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
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"
|
||||
>
|
||||
{getTagName(tag)}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</span>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
</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
|
||||
</span>
|
||||
</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="relative z-10 text-center">
|
||||
<div className="w-12 h-12 bg-white rounded-full border-2 border-black flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
||||
<span className="material-icons text-2xl">rocket_launch</span>
|
||||
<span className="material-icons text-2xl" aria-hidden="true">rocket_launch</span>
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-xl mb-2">{t('buildYourOwnAgent')}</h3>
|
||||
<p className="text-sm font-medium mb-6">{t('buildAgentDesc')}</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface RelatedProjectsProps {
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
<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 =
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
const icon = getProjectIcon(project.tags)
|
||||
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -78,14 +77,14 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
|
||||
key={tag.id}
|
||||
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
|
||||
>
|
||||
{getTagName(tag)}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs font-display font-bold uppercase flex items-center">
|
||||
{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
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProjectSortOption,
|
||||
TagWithProjectCount,
|
||||
} from '@/hooks/useProjects'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface TagFilterPanelProps {
|
||||
locale: string
|
||||
@@ -267,8 +268,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && typeOption.nameEn ? typeOption.nameEn : typeOption.name
|
||||
const displayName = getLocalizedTagName(typeOption, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -332,8 +332,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && domainTag.nameEn ? domainTag.nameEn : domainTag.name
|
||||
const displayName = getLocalizedTagName(domainTag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -363,10 +362,9 @@ export function TagFilterPanel({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedDomains.map((domainSlug) => {
|
||||
const matchedDomain = domainBySlug.get(domainSlug)
|
||||
const displayName =
|
||||
matchedDomain && locale === 'en' && matchedDomain.nameEn
|
||||
? matchedDomain.nameEn
|
||||
: matchedDomain?.name || domainSlug
|
||||
const displayName = matchedDomain
|
||||
? getLocalizedTagName(matchedDomain, locale)
|
||||
: domainSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -427,8 +425,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && productFormTag.nameEn ? productFormTag.nameEn : productFormTag.name
|
||||
const displayName = getLocalizedTagName(productFormTag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -458,10 +455,9 @@ export function TagFilterPanel({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedProductForms.map((productFormSlug) => {
|
||||
const matchedProductForm = productFormBySlug.get(productFormSlug)
|
||||
const displayName =
|
||||
matchedProductForm && locale === 'en' && matchedProductForm.nameEn
|
||||
? matchedProductForm.nameEn
|
||||
: matchedProductForm?.name || productFormSlug
|
||||
const displayName = matchedProductForm
|
||||
? getLocalizedTagName(matchedProductForm, locale)
|
||||
: productFormSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -545,10 +541,7 @@ export function TagFilterPanel({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map((tagSlug) => {
|
||||
const matchedTag = tagBySlug.get(tagSlug)
|
||||
const displayName =
|
||||
matchedTag && locale === 'en' && matchedTag.nameEn
|
||||
? matchedTag.nameEn
|
||||
: matchedTag?.name || tagSlug
|
||||
const displayName = matchedTag ? getLocalizedTagName(matchedTag, locale) : tagSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -638,7 +631,7 @@ export function TagFilterPanel({
|
||||
selectedTags: toggleTag(tag.slug),
|
||||
sort,
|
||||
})
|
||||
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
const displayName = getLocalizedTagName(tag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -55,6 +55,14 @@ interface SignalsApiResponse {
|
||||
items: IdeaSignal[]
|
||||
nextCursor: string | null
|
||||
hasMore: boolean
|
||||
meta: {
|
||||
totalCount: number
|
||||
hotCount: number
|
||||
newestPublishedAt: string | null
|
||||
}
|
||||
facets: {
|
||||
sourceCounts: Partial<Record<SignalSource, number>>
|
||||
}
|
||||
}
|
||||
|
||||
type SortKey = 'latest' | 'hot'
|
||||
@@ -99,6 +107,7 @@ interface SourceMeta {
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value))
|
||||
@@ -217,6 +226,18 @@ async function fetchSignals(params: {
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
nextCursor: typeof data.nextCursor === 'string' ? data.nextCursor : null,
|
||||
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 [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
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 [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
@@ -270,12 +294,18 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
setSignals(page.items)
|
||||
setNextCursor(page.nextCursor)
|
||||
setHasMore(page.hasMore)
|
||||
setTotalCount(page.meta.totalCount)
|
||||
setHotCount(page.meta.hotCount)
|
||||
setSourceCounts(page.facets.sourceCounts)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('[Signals] failed to load first page:', error)
|
||||
setSignals([])
|
||||
setNextCursor(null)
|
||||
setHasMore(false)
|
||||
setTotalCount(0)
|
||||
setHotCount(0)
|
||||
setSourceCounts({})
|
||||
setLoadError(true)
|
||||
}
|
||||
} finally {
|
||||
@@ -293,6 +323,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
}, [debouncedSearch, locale, sort, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'all' && !isLoading && (sourceCounts[source] || 0) === 0) {
|
||||
setSource('all')
|
||||
}
|
||||
}, [isLoading, source, sourceCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortMenuOpen) {
|
||||
return
|
||||
@@ -342,6 +378,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
setSignals((previous) => [...previous, ...page.items])
|
||||
setNextCursor(page.nextCursor)
|
||||
setHasMore(page.hasMore)
|
||||
setTotalCount(page.meta.totalCount)
|
||||
setHotCount(page.meta.hotCount)
|
||||
setSourceCounts(page.facets.sourceCounts)
|
||||
} catch (error) {
|
||||
console.error('[Signals] failed to load more:', error)
|
||||
setLoadError(true)
|
||||
@@ -384,7 +423,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<span
|
||||
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"
|
||||
>
|
||||
<List className="h-3 w-3" aria-hidden="true" />
|
||||
{signals.length}
|
||||
{totalCount}
|
||||
</span>
|
||||
<span
|
||||
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="flex min-w-max gap-1.5">
|
||||
{sourceOptions.map((option) => {
|
||||
{visibleSourceOptions.map((option) => {
|
||||
const active = source === option
|
||||
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
||||
const SourceIcon = meta.icon
|
||||
const count = option === 'all' ? totalCount : sourceCounts[option] || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -492,6 +537,7 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sourceLabels[option]}
|
||||
{count > 0 ? <span className="opacity-70">{count}</span> : null}
|
||||
</span>
|
||||
</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);
|
||||
}
|
||||
|
||||
async function buildHomePageData(): Promise<HomePageData> {
|
||||
export async function buildHomePageData(): Promise<HomePageData> {
|
||||
const now = Date.now();
|
||||
const last24Hours = new Date(now - ONE_DAY_MS);
|
||||
const last7Days = new Date(now - ONE_DAY_MS * 7);
|
||||
@@ -171,7 +171,13 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
topStars,
|
||||
topTags,
|
||||
] = await Promise.all([
|
||||
safeQuery("countTotalProjects", 0, () => prisma.project.count()),
|
||||
safeQuery("countTotalProjects", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
})
|
||||
),
|
||||
safeQuery("countNewProjects30d", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
@@ -204,7 +210,7 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
),
|
||||
getLatestProjects(latestProjectsLimit),
|
||||
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
||||
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
||||
safeQuery("getTopTags", [], () => getTopTags(DEFAULT_TOP_TAG_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",
|
||||
"showFilterPanel": "Show 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",
|
||||
"viewAll": "View All",
|
||||
"addedOn": "Added {date}",
|
||||
@@ -178,7 +180,25 @@
|
||||
"principleUsefulDescription": "The structure is designed to answer practical questions: when to use it, why, and with what.",
|
||||
"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.",
|
||||
"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": {
|
||||
"metaTitle": "Frontier Signals - Agent Park",
|
||||
@@ -229,7 +249,9 @@
|
||||
"projects": "Projects",
|
||||
"signals": "Signals",
|
||||
"about": "About",
|
||||
"submitProject": "SUBMIT PROJECT"
|
||||
"submitProject": "SUBMIT PROJECT",
|
||||
"menu": "Open menu",
|
||||
"closeMenu": "Close menu"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page Not Found",
|
||||
@@ -245,6 +267,7 @@
|
||||
"email": "E-mail",
|
||||
"subscribe": "SUBSCRIBE",
|
||||
"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.",
|
||||
"resources": "Resources",
|
||||
"resourceNewsletter": "Newsletter",
|
||||
@@ -257,5 +280,47 @@
|
||||
"closeAnnouncement": "Close announcement",
|
||||
"copyright": "© 2025 Agent Park. All rights reserved.",
|
||||
"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} 条",
|
||||
"showFilterPanel": "展开筛选器",
|
||||
"hideFilterPanel": "收起筛选器",
|
||||
"aiSearchUnavailableFallback": "AI 搜索暂不可用,已显示普通搜索结果。",
|
||||
"aiSearchEmptyFallback": "暂无语义搜索结果,已显示普通搜索结果。",
|
||||
"relatedProjects": "相关项目",
|
||||
"viewAll": "查看全部",
|
||||
"addedOn": "收录于 {date}",
|
||||
@@ -178,7 +180,25 @@
|
||||
"principleUsefulDescription": "内容组织优先服务“怎么用、何时用、和谁一起用”的实际决策。",
|
||||
"closingTitle": "一起把这个目录做得更好",
|
||||
"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": {
|
||||
"metaTitle": "前沿信号 - Agent Park",
|
||||
@@ -229,7 +249,9 @@
|
||||
"projects": "项目列表",
|
||||
"signals": "前沿信号",
|
||||
"about": "关于",
|
||||
"submitProject": "提交项目"
|
||||
"submitProject": "提交项目",
|
||||
"menu": "打开菜单",
|
||||
"closeMenu": "关闭菜单"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "页面未找到",
|
||||
@@ -245,6 +267,7 @@
|
||||
"email": "电子邮箱",
|
||||
"subscribe": "订阅",
|
||||
"subscribeConsent": "订阅 Agent Park 更新",
|
||||
"newsletterUnavailable": "订阅功能正在接入中,当前不会保存邮箱。",
|
||||
"footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
|
||||
"resources": "资源",
|
||||
"resourceNewsletter": "新闻订阅",
|
||||
@@ -257,5 +280,47 @@
|
||||
"closeAnnouncement": "关闭公告",
|
||||
"copyright": "© 2025 Agent Park. 保留所有权利。",
|
||||
"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