Compare commits
5
Commits
804fd756e7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d4f09b72 | ||
|
|
3e368e663e | ||
|
|
54ff5ba89d | ||
|
|
70725e3e52 | ||
|
|
79744667c6 |
+5
-5
@@ -1,10 +1,5 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
|
||||
PG_SSL_ROOT_CERT_B64=""
|
||||
PG_SSL_IDENTITY_P12_B64=""
|
||||
PG_SSL_IDENTITY_PASSWORD=""
|
||||
# Optional override when your proxy hostname or certificate policy differs
|
||||
# PG_SSL_MODE="verify-full"
|
||||
|
||||
# Webhook API - Generate a secure key for production
|
||||
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
||||
@@ -15,3 +10,8 @@ N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
||||
# Internationalization
|
||||
NEXT_INTL_DEFAULT_LOCALE="zh"
|
||||
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
|
||||
|
||||
# Optional self-hosted analytics
|
||||
NEXT_PUBLIC_UMAMI_HOST_URL="https://analytics.example.com"
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-umami-website-id"
|
||||
NEXT_PUBLIC_UMAMI_DOMAINS="example.com,www.example.com"
|
||||
|
||||
@@ -10,6 +10,7 @@ WORKDIR /app
|
||||
FROM base AS deps
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma/schema.prisma ./prisma/schema.prisma
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
+2
-2
@@ -6,14 +6,14 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"db:generate": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate",
|
||||
"test": "vitest",
|
||||
"n8n:context": "node scripts/generate-n8n-context.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.1.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.11",
|
||||
"next-intl": "^4.0.2",
|
||||
|
||||
Generated
-65
@@ -11,12 +11,6 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^6.1.0
|
||||
version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@vercel/analytics':
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
'@vercel/speed-insights':
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@19.2.3)
|
||||
@@ -1044,55 +1038,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vercel/analytics@1.6.1':
|
||||
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
|
||||
peerDependencies:
|
||||
'@remix-run/react': ^2
|
||||
'@sveltejs/kit': ^1 || ^2
|
||||
next: '>= 13'
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
svelte: '>= 4'
|
||||
vue: ^3
|
||||
vue-router: ^4
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
vue-router:
|
||||
optional: true
|
||||
|
||||
'@vercel/speed-insights@1.3.1':
|
||||
resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^1 || ^2
|
||||
next: '>= 13'
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
svelte: '>= 4'
|
||||
vue: ^3
|
||||
vue-router: ^4
|
||||
peerDependenciesMeta:
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
vue-router:
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
@@ -3797,16 +3742,6 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@vercel/analytics@1.6.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
|
||||
optionalDependencies:
|
||||
next: 15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
|
||||
'@vercel/speed-insights@1.3.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
|
||||
optionalDependencies:
|
||||
next: 15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type
|
||||
WHERE typname = 'TaskStatus'
|
||||
) THEN
|
||||
CREATE TYPE "TaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "project_discovery_tasks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "TaskStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"sourceUrl" TEXT NOT NULL,
|
||||
"sourceType" TEXT NOT NULL DEFAULT 'manual',
|
||||
"explorationData" JSONB,
|
||||
"explorationSummary" TEXT,
|
||||
"errorMessage" TEXT,
|
||||
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastRetryAt" TIMESTAMP(3),
|
||||
"projectId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_discovery_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_project_id" ON "project_discovery_tasks"("projectId");
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_source_url" ON "project_discovery_tasks"("sourceUrl");
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_status_created" ON "project_discovery_tasks"("status", "createdAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'project_discovery_tasks_projectId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "project_discovery_tasks"
|
||||
ADD CONSTRAINT "project_discovery_tasks_projectId_fkey"
|
||||
FOREIGN KEY ("projectId")
|
||||
REFERENCES "projects"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -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");
|
||||
+75
-18
@@ -25,6 +25,29 @@ model ExternalLink {
|
||||
@@map("external_links")
|
||||
}
|
||||
|
||||
model ProjectDiscoveryTask {
|
||||
id String @id @default(cuid())
|
||||
status TaskStatus @default(PENDING)
|
||||
sourceUrl String
|
||||
sourceType String @default("manual")
|
||||
explorationData Json?
|
||||
explorationSummary String?
|
||||
errorMessage String?
|
||||
retryCount Int @default(0)
|
||||
lastRetryAt DateTime?
|
||||
projectId String?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
|
||||
@@index([projectId], map: "idx_task_project_id")
|
||||
@@index([sourceUrl], map: "idx_task_source_url")
|
||||
@@index([status, createdAt], map: "idx_task_status_created")
|
||||
@@map("project_discovery_tasks")
|
||||
}
|
||||
|
||||
model ProjectTag {
|
||||
projectId String
|
||||
tagId String
|
||||
@@ -37,24 +60,25 @@ model ProjectTag {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
tags ProjectTag[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
projectDiscoveryTasks ProjectDiscoveryTask[]
|
||||
tags ProjectTag[]
|
||||
|
||||
@@index([embedding], map: "idx_project_embedding_cosine")
|
||||
@@index([slug], map: "idx_project_slug")
|
||||
@@ -106,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
|
||||
@@ -118,6 +161,20 @@ enum ProjectStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ProjectSubmissionStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
IMPORTED
|
||||
}
|
||||
|
||||
enum TagCategory {
|
||||
FIXED_PROJECT_TYPE
|
||||
TECH_STACK
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Script from "next/script";
|
||||
|
||||
const beforeSendScript = `
|
||||
window.umamiBeforeSend = function (_type, payload) {
|
||||
try {
|
||||
var rawUrl = payload && typeof payload.url === "string" ? payload.url : window.location.href;
|
||||
var pathname = new URL(rawUrl, window.location.origin).pathname;
|
||||
var isTrackable =
|
||||
pathname === "/zh" ||
|
||||
pathname === "/en" ||
|
||||
pathname.startsWith("/zh/") ||
|
||||
pathname.startsWith("/en/");
|
||||
|
||||
return isTrackable ? payload : false;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
export function UmamiMetrics() {
|
||||
const hostUrl = process.env.NEXT_PUBLIC_UMAMI_HOST_URL?.replace(/\/+$/, "");
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
|
||||
|
||||
if (!hostUrl || !websiteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="umami-before-send" strategy="beforeInteractive">
|
||||
{beforeSendScript}
|
||||
</Script>
|
||||
<Script
|
||||
id="umami-script"
|
||||
src={`${hostUrl}/script.js`}
|
||||
strategy="afterInteractive"
|
||||
data-website-id={websiteId}
|
||||
data-host-url={hostUrl}
|
||||
data-domains={domains || undefined}
|
||||
data-before-send="umamiBeforeSend"
|
||||
data-exclude-search="true"
|
||||
data-exclude-hash="true"
|
||||
data-do-not-track="true"
|
||||
data-performance="true"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next"
|
||||
|
||||
const isTrackablePath = (url: string): boolean => {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
|
||||
// Only keep user-facing locale pages to control usage on the Hobby plan.
|
||||
return pathname === "/zh" || pathname === "/en" || pathname.startsWith("/zh/") || pathname.startsWith("/en/")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function VercelMetrics() {
|
||||
return (
|
||||
<>
|
||||
<Analytics mode="production" beforeSend={(event) => (isTrackablePath(event.url) ? event : null)} />
|
||||
<SpeedInsights sampleRate={0.2} beforeSend={(data) => (isTrackablePath(data.url) ? data : null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+30
-103
@@ -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'}`}
|
||||
/>
|
||||
<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>
|
||||
<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'),
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
<NewsletterSignup
|
||||
labels={{
|
||||
email: t('email'),
|
||||
subscribe: t('subscribe'),
|
||||
consent: t('subscribeConsent'),
|
||||
unavailable: t('newsletterUnavailable'),
|
||||
}}
|
||||
/>
|
||||
</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,189 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CheckTaskDuplicatesSchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
type DuplicateCheckResult = {
|
||||
url: string;
|
||||
shouldCreate: boolean;
|
||||
reason: string;
|
||||
existingTask?: {
|
||||
id: string;
|
||||
status: string;
|
||||
sourceUrl: string;
|
||||
createdAt: Date;
|
||||
projectId?: string | null;
|
||||
};
|
||||
existingProject?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function checkUrlDuplicate(url: string): Promise<DuplicateCheckResult> {
|
||||
const activeTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ["PENDING", "IN_PROGRESS"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTask) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: `Task already exists with status ${activeTask.status}`,
|
||||
existingTask: activeTask,
|
||||
};
|
||||
}
|
||||
|
||||
const finishedTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ["COMPLETED", "FAILED"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (finishedTask) {
|
||||
let projectInfo;
|
||||
|
||||
if (finishedTask.status === "COMPLETED" && finishedTask.projectId) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: finishedTask.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
projectInfo = project;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason:
|
||||
finishedTask.status === "COMPLETED" ? "Task already completed" : "Task already failed",
|
||||
existingTask: finishedTask,
|
||||
existingProject: projectInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const existingLink = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
url,
|
||||
},
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: "Project already exists with this URL",
|
||||
existingProject: existingLink.project,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
shouldCreate: true,
|
||||
reason: "No existing task or project found",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validationResult = CheckTaskDuplicatesSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validationResult.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, urls } = validationResult.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
details: ["Invalid or missing API Key"],
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(urls.map((url) => checkUrlDuplicate(url)));
|
||||
const stats = {
|
||||
total: results.length,
|
||||
shouldCreate: results.filter((result) => result.shouldCreate).length,
|
||||
duplicate: results.filter((result) => !result.shouldCreate).length,
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.warn(
|
||||
`[CheckTaskDuplicates] Checked ${stats.total} URLs in ${duration}ms: ${stats.shouldCreate} should create, ${stats.duplicate} duplicate`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CheckTaskDuplicates] Error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ProjectInput } from "@/lib/validations";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
FIXED_PROJECT_TYPE_TAGS,
|
||||
inferProjectTypeSlug,
|
||||
inferTagCategory,
|
||||
type FixedProjectTypeSlug,
|
||||
} from "@/lib/tag-taxonomy";
|
||||
|
||||
const PLURAL_NORMALIZATION_MAP = new Map([
|
||||
["agents", "agent"],
|
||||
["assistants", "assistant"],
|
||||
["tools", "tool"],
|
||||
["frameworks", "framework"],
|
||||
["models", "model"],
|
||||
["servers", "server"],
|
||||
["clients", "client"],
|
||||
["workflows", "workflow"],
|
||||
["plugins", "plugin"],
|
||||
["libraries", "library"],
|
||||
["datasets", "dataset"],
|
||||
["platforms", "platform"],
|
||||
["systems", "system"],
|
||||
["repositories", "repository"],
|
||||
["engines", "engine"],
|
||||
]);
|
||||
|
||||
const NOISE_TAG_KEYS = new Set([
|
||||
"ai",
|
||||
"artificial intelligence",
|
||||
"open source",
|
||||
"requires configuration",
|
||||
"requires basics",
|
||||
"low learning curve",
|
||||
"enterprise",
|
||||
"complex deployment",
|
||||
"cloud service",
|
||||
"cross platform",
|
||||
"tutorial",
|
||||
"academic research",
|
||||
"academic resource",
|
||||
"ai research resource",
|
||||
"multi language support",
|
||||
"self hosted",
|
||||
"user notification",
|
||||
"mit license",
|
||||
"apache 2 0",
|
||||
"开源",
|
||||
"需要配置",
|
||||
"需要基础",
|
||||
"低学习成本",
|
||||
"企业级",
|
||||
"复杂部署",
|
||||
"云端服务",
|
||||
"跨平台",
|
||||
"教程",
|
||||
"学术研究",
|
||||
"学术资源",
|
||||
"多语言支持",
|
||||
"自托管",
|
||||
"用户通知",
|
||||
"许可",
|
||||
]);
|
||||
|
||||
const TAG_FALLBACK = {
|
||||
name: "AI开发工具",
|
||||
nameEn: "AI Development Tool",
|
||||
} as const;
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isAsciiText(value: string): boolean {
|
||||
return /^[\x00-\x7f]+$/.test(value);
|
||||
}
|
||||
|
||||
function canonicalizeTagKey(value: string): string {
|
||||
const normalized = normalizeWhitespace(value)
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[+/_&|-]+/g, " ")
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(" ")
|
||||
.map((word) => PLURAL_NORMALIZATION_MAP.get(word) || word)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeIncomingTag(tag: ProjectInput["tags"][number]) {
|
||||
const normalizedName = normalizeWhitespace(tag.name);
|
||||
const normalizedNameEn = normalizeWhitespace(tag.nameEn || "");
|
||||
const resolvedNameEn = normalizedNameEn || (isAsciiText(normalizedName) ? normalizedName : "");
|
||||
const canonicalNameKey = canonicalizeTagKey(normalizedName);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(resolvedNameEn);
|
||||
|
||||
return {
|
||||
name: normalizedName,
|
||||
nameEn: resolvedNameEn || null,
|
||||
canonicalNameKey,
|
||||
canonicalNameEnKey,
|
||||
slug: generateSlug(normalizedName, resolvedNameEn || null),
|
||||
};
|
||||
}
|
||||
|
||||
function isMeaningfulTag(tag: ReturnType<typeof normalizeIncomingTag>): boolean {
|
||||
if (!tag.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (NOISE_TAG_KEYS.has(tag.canonicalNameKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tag.canonicalNameEnKey && NOISE_TAG_KEYS.has(tag.canonicalNameEnKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||
include: {
|
||||
_count: { select: { projects: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const projectDiff = b._count.projects - a._count.projects;
|
||||
if (projectDiff !== 0) {
|
||||
return projectDiff;
|
||||
}
|
||||
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
})[0]!;
|
||||
}
|
||||
|
||||
type IncomingNormalizedTag = ReturnType<typeof normalizeIncomingTag>;
|
||||
|
||||
type TagLookupMaps = {
|
||||
tagByExactName: Map<string, TagWithProjectCount[]>;
|
||||
tagByExactNameEn: Map<string, TagWithProjectCount[]>;
|
||||
tagBySlug: Map<string, TagWithProjectCount>;
|
||||
tagByCanonicalName: Map<string, TagWithProjectCount[]>;
|
||||
tagByCanonicalNameEn: Map<string, TagWithProjectCount[]>;
|
||||
};
|
||||
|
||||
function pushTagMapEntry(
|
||||
map: Map<string, TagWithProjectCount[]>,
|
||||
key: string,
|
||||
tag: TagWithProjectCount
|
||||
): void {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = map.get(key);
|
||||
if (current) {
|
||||
current.push(tag);
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(key, [tag]);
|
||||
}
|
||||
|
||||
function createTagLookupMaps(tags: TagWithProjectCount[]): TagLookupMaps {
|
||||
const tagByExactName = new Map<string, TagWithProjectCount[]>();
|
||||
const tagByExactNameEn = new Map<string, TagWithProjectCount[]>();
|
||||
const tagBySlug = new Map<string, TagWithProjectCount>();
|
||||
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>();
|
||||
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const exactNameKey = normalizeWhitespace(tag.name);
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || "");
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || "");
|
||||
|
||||
pushTagMapEntry(tagByExactName, exactNameKey, tag);
|
||||
pushTagMapEntry(tagByExactNameEn, exactNameEnKey, tag);
|
||||
pushTagMapEntry(tagByCanonicalName, canonicalNameKey, tag);
|
||||
pushTagMapEntry(tagByCanonicalNameEn, canonicalNameEnKey, tag);
|
||||
tagBySlug.set(tag.slug, tag);
|
||||
}
|
||||
|
||||
return {
|
||||
tagByExactName,
|
||||
tagByExactNameEn,
|
||||
tagBySlug,
|
||||
tagByCanonicalName,
|
||||
tagByCanonicalNameEn,
|
||||
};
|
||||
}
|
||||
|
||||
function addTagToLookupMaps(lookups: TagLookupMaps, tag: TagWithProjectCount): void {
|
||||
const exactNameKey = normalizeWhitespace(tag.name);
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || "");
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || "");
|
||||
|
||||
pushTagMapEntry(lookups.tagByExactName, exactNameKey, tag);
|
||||
pushTagMapEntry(lookups.tagByExactNameEn, exactNameEnKey, tag);
|
||||
pushTagMapEntry(lookups.tagByCanonicalName, canonicalNameKey, tag);
|
||||
pushTagMapEntry(lookups.tagByCanonicalNameEn, canonicalNameEnKey, tag);
|
||||
lookups.tagBySlug.set(tag.slug, tag);
|
||||
}
|
||||
|
||||
async function getCandidateTags(
|
||||
incomingTags: IncomingNormalizedTag[]
|
||||
): Promise<TagWithProjectCount[]> {
|
||||
const nameValues = Array.from(
|
||||
new Set(
|
||||
incomingTags.map((tag) => normalizeWhitespace(tag.name)).filter((name) => name.length > 0)
|
||||
)
|
||||
);
|
||||
const slugValues = Array.from(
|
||||
new Set(incomingTags.map((tag) => tag.slug).filter((slug) => slug.length > 0))
|
||||
);
|
||||
const nameEnValues = Array.from(
|
||||
new Set(
|
||||
incomingTags
|
||||
.map((tag) => normalizeWhitespace(tag.nameEn || ""))
|
||||
.filter((nameEn) => nameEn.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const whereOr: Prisma.TagWhereInput[] = [];
|
||||
if (nameValues.length > 0) {
|
||||
whereOr.push({ name: { in: nameValues } });
|
||||
}
|
||||
if (slugValues.length > 0) {
|
||||
whereOr.push({ slug: { in: slugValues } });
|
||||
}
|
||||
if (nameEnValues.length > 0) {
|
||||
whereOr.push({ nameEn: { in: nameEnValues } });
|
||||
}
|
||||
|
||||
if (whereOr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.tag.findMany({
|
||||
where: {
|
||||
OR: whereOr,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
||||
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn);
|
||||
const fallbackCategory = inferTagCategory({
|
||||
slug: fallbackSlug,
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
});
|
||||
|
||||
return prisma.tag.upsert({
|
||||
where: { slug: fallbackSlug },
|
||||
update: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
create: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
slug: fallbackSlug,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const FIXED_PROJECT_TYPE_MAP = new Map(
|
||||
FIXED_PROJECT_TYPE_TAGS.map((tag) => [tag.slug, tag] as const)
|
||||
);
|
||||
|
||||
export async function ensureFixedProjectTypeTag(
|
||||
slug: FixedProjectTypeSlug
|
||||
): Promise<TagWithProjectCount> {
|
||||
const fixedTag = FIXED_PROJECT_TYPE_MAP.get(slug);
|
||||
if (!fixedTag) {
|
||||
throw new Error(`Unsupported fixed project type slug: ${slug}`);
|
||||
}
|
||||
|
||||
return prisma.tag.upsert({
|
||||
where: { slug: fixedTag.slug },
|
||||
update: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
category: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
create: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
slug: fixedTag.slug,
|
||||
category: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFixedProjectTypeTag(
|
||||
projectData: Pick<ProjectInput, "name" | "nameEn" | "description" | "descriptionEn" | "tags">
|
||||
): Promise<TagWithProjectCount> {
|
||||
const projectTypeSlug = inferProjectTypeSlug({
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn,
|
||||
tags: projectData.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
nameEn: tag.nameEn,
|
||||
slug: generateSlug(tag.name, tag.nameEn || null),
|
||||
})),
|
||||
});
|
||||
|
||||
return ensureFixedProjectTypeTag(projectTypeSlug);
|
||||
}
|
||||
|
||||
export async function findExistingProject(projectData: ProjectInput) {
|
||||
const githubLink = projectData.links.find((link) => link.type === "GITHUB");
|
||||
if (githubLink) {
|
||||
const existingByGithub = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
type: "GITHUB",
|
||||
url: githubLink.url,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByGithub) {
|
||||
console.warn(`[Discovery] Found existing project by GitHub URL: ${githubLink.url}`);
|
||||
return existingByGithub.project;
|
||||
}
|
||||
}
|
||||
|
||||
const websiteLink = projectData.links.find((link) => link.type === "WEBSITE");
|
||||
if (websiteLink) {
|
||||
const existingByWebsite = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
type: "WEBSITE",
|
||||
url: websiteLink.url,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByWebsite) {
|
||||
console.warn(`[Discovery] Found existing project by Website URL: ${websiteLink.url}`);
|
||||
return existingByWebsite.project;
|
||||
}
|
||||
}
|
||||
|
||||
const slug = generateSlug(projectData.name, projectData.nameEn);
|
||||
const existingBySlug = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingBySlug) {
|
||||
console.warn(`[Discovery] Found existing project by slug: ${slug}`);
|
||||
return existingBySlug;
|
||||
}
|
||||
|
||||
console.warn("[Discovery] No existing project found, will create new one");
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function upsertTags(tags: ProjectInput["tags"]) {
|
||||
const meaningfulTags = tags
|
||||
.map((tag) => normalizeIncomingTag(tag))
|
||||
.filter((tag) => isMeaningfulTag(tag));
|
||||
|
||||
const normalizedIncomingTags = Array.from(
|
||||
new Map(
|
||||
meaningfulTags.map((normalized) => [
|
||||
normalized.canonicalNameEnKey || normalized.canonicalNameKey || normalized.name,
|
||||
normalized,
|
||||
])
|
||||
).values()
|
||||
);
|
||||
|
||||
if (normalizedIncomingTags.length === 0) {
|
||||
return [await getFallbackTag()];
|
||||
}
|
||||
|
||||
const candidateTags = await getCandidateTags(normalizedIncomingTags);
|
||||
const lookups = createTagLookupMaps(candidateTags);
|
||||
const resolvedTags: TagWithProjectCount[] = [];
|
||||
|
||||
for (const incomingTag of normalizedIncomingTags) {
|
||||
const exactNameCandidates = lookups.tagByExactName.get(incomingTag.name) || [];
|
||||
let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null;
|
||||
|
||||
if (!matchedTag && incomingTag.nameEn) {
|
||||
const exactNameEnCandidates = lookups.tagByExactNameEn.get(incomingTag.nameEn) || [];
|
||||
if (exactNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(exactNameEnCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameKey) {
|
||||
const canonicalNameCandidates =
|
||||
lookups.tagByCanonicalName.get(incomingTag.canonicalNameKey) || [];
|
||||
if (canonicalNameCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameEnKey) {
|
||||
const canonicalNameEnCandidates =
|
||||
lookups.tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || [];
|
||||
if (canonicalNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameEnCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag) {
|
||||
matchedTag = lookups.tagBySlug.get(incomingTag.slug) || null;
|
||||
}
|
||||
|
||||
if (matchedTag) {
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
});
|
||||
const shouldUpdateNameEn = incomingTag.nameEn && !matchedTag.nameEn;
|
||||
const shouldUpdateCategory =
|
||||
matchedTag.category !== inferredCategory &&
|
||||
["FREE_TAG", "RESOURCE_TYPE", "PROTOCOL_INTERFACE"].includes(matchedTag.category);
|
||||
|
||||
if (shouldUpdateNameEn || shouldUpdateCategory) {
|
||||
const updatedTag = await prisma.tag.update({
|
||||
where: { id: matchedTag.id },
|
||||
data: {
|
||||
...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}),
|
||||
...(shouldUpdateCategory ? { category: inferredCategory } : {}),
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
matchedTag = updatedTag;
|
||||
addTagToLookupMaps(lookups, matchedTag);
|
||||
}
|
||||
|
||||
resolvedTags.push(matchedTag);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
});
|
||||
|
||||
try {
|
||||
const createdTag = await prisma.tag.create({
|
||||
data: {
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
slug: incomingTag.slug,
|
||||
category: inferredCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
resolvedTags.push(createdTag);
|
||||
addTagToLookupMaps(lookups, createdTag);
|
||||
} catch {
|
||||
const fallbackTag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
OR: [{ name: incomingTag.name }, { slug: incomingTag.slug }],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (fallbackTag) {
|
||||
resolvedTags.push(fallbackTag);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to resolve tag: ${incomingTag.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Map(resolvedTags.map((tag) => [tag.id, tag])).values());
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
import { ProjectInputSchema, type ProjectInput } from "@/lib/validations";
|
||||
import {
|
||||
findExistingProject,
|
||||
resolveFixedProjectTypeTag,
|
||||
upsertTags,
|
||||
} from "@/app/api/discovery/lib/discovery-service";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const startTime = Date.now();
|
||||
const { id: taskId } = await params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { apiKey, explorationData } = body;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const projectValidation = ProjectInputSchema.safeParse(explorationData);
|
||||
if (!projectValidation.success) {
|
||||
console.error(
|
||||
`[Discovery] Invalid exploration data for task ${taskId}:`,
|
||||
projectValidation.error.errors
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid exploration data format",
|
||||
details: projectValidation.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectData = projectValidation.data as ProjectInput;
|
||||
const existingProject = await findExistingProject(projectData);
|
||||
|
||||
const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
|
||||
upsertTags(projectData.tags),
|
||||
resolveFixedProjectTypeTag(projectData),
|
||||
]);
|
||||
const tagConnections = Array.from(
|
||||
new Map([...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])).values()
|
||||
);
|
||||
|
||||
const slug = generateSlug(projectData.name, projectData.nameEn);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
let projectId: string;
|
||||
|
||||
if (existingProject) {
|
||||
console.warn(
|
||||
`[Discovery] Updating existing project for task ${taskId}: ${projectData.name}`
|
||||
);
|
||||
|
||||
await tx.projectTag.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
});
|
||||
|
||||
await tx.project.update({
|
||||
where: { id: existingProject.id },
|
||||
data: {
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn || null,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn || null,
|
||||
content: projectData.content || null,
|
||||
contentEn: projectData.contentEn || null,
|
||||
status: projectData.status,
|
||||
source: projectData.source || "discovery",
|
||||
tags: {
|
||||
create: tagConnections.map((tag) => ({
|
||||
tag: { connect: { id: tag.id } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.externalLink.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
});
|
||||
|
||||
await tx.externalLink.createMany({
|
||||
data: projectData.links.map((link) => ({
|
||||
type: link.type,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
projectId: existingProject.id,
|
||||
})),
|
||||
});
|
||||
|
||||
projectId = existingProject.id;
|
||||
} else {
|
||||
console.warn(`[Discovery] Creating new project for task ${taskId}: ${projectData.name}`);
|
||||
|
||||
const newProject = await tx.project.create({
|
||||
data: {
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn || null,
|
||||
slug,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn || null,
|
||||
content: projectData.content || null,
|
||||
contentEn: projectData.contentEn || null,
|
||||
status: projectData.status,
|
||||
source: projectData.source || "discovery",
|
||||
tags: {
|
||||
create: tagConnections.map((tag) => ({
|
||||
tag: { connect: { id: tag.id } },
|
||||
})),
|
||||
},
|
||||
links: {
|
||||
create: projectData.links.map((link) => ({
|
||||
type: link.type,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
projectId = newProject.id;
|
||||
}
|
||||
|
||||
const updatedTask = await tx.projectDiscoveryTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
explorationData,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return { projectId, updatedTask };
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.warn(
|
||||
`[Discovery] Completed task ${taskId} in ${duration}ms, project: ${result.projectId}`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId,
|
||||
projectId: result.projectId,
|
||||
action: existingProject ? "updated" : "created",
|
||||
duration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error completing task:", error);
|
||||
|
||||
try {
|
||||
await prisma.projectDiscoveryTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
completedAt: new Date(),
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
retryCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} catch (updateError) {
|
||||
console.error("[Discovery] Failed to update task status:", updateError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { UpdateDiscoveryTaskSchema, type TaskStatus } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
const VALID_STATUS_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
||||
PENDING: ["IN_PROGRESS"],
|
||||
IN_PROGRESS: ["COMPLETED", "FAILED"],
|
||||
COMPLETED: [],
|
||||
FAILED: ["PENDING", "IN_PROGRESS"],
|
||||
};
|
||||
|
||||
function isValidStatusTransition(from: TaskStatus, to: TaskStatus): boolean {
|
||||
return VALID_STATUS_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const apiKey = request.headers.get("x-api-key") || request.nextUrl.searchParams.get("apiKey");
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const task = await prisma.projectDiscoveryTask.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ success: false, error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error fetching task:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const validation = UpdateDiscoveryTaskSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, status, explorationData, explorationSummary, errorMessage } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const existingTask = await prisma.projectDiscoveryTask.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json({ success: false, error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!isValidStatusTransition(existingTask.status, status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid status transition",
|
||||
details: [
|
||||
`Cannot transition from ${existingTask.status} to ${status}. Valid transitions: ${VALID_STATUS_TRANSITIONS[existingTask.status].join(", ")}`,
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskUpdateData {
|
||||
status: TaskStatus;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
explorationData?: Prisma.InputJsonValue;
|
||||
explorationSummary?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
const updateData: TaskUpdateData = { status };
|
||||
|
||||
if (status === "IN_PROGRESS") {
|
||||
updateData.startedAt = new Date();
|
||||
} else if (status === "COMPLETED" || status === "FAILED") {
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
|
||||
if (explorationData !== undefined) {
|
||||
updateData.explorationData = explorationData as Prisma.InputJsonObject;
|
||||
}
|
||||
if (explorationSummary !== undefined) {
|
||||
updateData.explorationSummary = explorationSummary;
|
||||
}
|
||||
if (errorMessage !== undefined) {
|
||||
updateData.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
const task = await prisma.projectDiscoveryTask.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
console.warn(`[Discovery] Updated task ${id} to status: ${status}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error updating task:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BatchResetTasksSchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = BatchResetTasksSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, taskIds, statuses } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const where = taskIds
|
||||
? { id: { in: taskIds } }
|
||||
: { status: { in: statuses || ["IN_PROGRESS", "FAILED"] } };
|
||||
|
||||
const result = await prisma.projectDiscoveryTask.updateMany({
|
||||
where,
|
||||
data: {
|
||||
status: "PENDING",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[Discovery] Batch reset ${result.count} tasks to PENDING. Condition: ${JSON.stringify(where)}`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
reset: result.count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error batch resetting tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CreateDiscoveryTaskSchema, GetDiscoveryTasksQuerySchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = CreateDiscoveryTaskSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, tasks } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const existingUrls = new Set(
|
||||
(
|
||||
await prisma.projectDiscoveryTask.findMany({
|
||||
where: { sourceUrl: { in: tasks.map((task) => task.sourceUrl) } },
|
||||
select: { sourceUrl: true },
|
||||
})
|
||||
).map((task) => task.sourceUrl)
|
||||
);
|
||||
|
||||
const newTasks = tasks.filter((task) => !existingUrls.has(task.sourceUrl));
|
||||
|
||||
if (newTasks.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
skipped: tasks.length,
|
||||
total: tasks.length,
|
||||
message: "All tasks already exist",
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prisma.projectDiscoveryTask.createMany({
|
||||
data: newTasks.map((task) => ({
|
||||
sourceUrl: task.sourceUrl,
|
||||
sourceType: task.sourceType,
|
||||
status: "PENDING",
|
||||
})),
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[Discovery] Created ${created.count} tasks, skipped ${tasks.length - created.count} existing tasks`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: created.count,
|
||||
skipped: tasks.length - created.count,
|
||||
total: tasks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error creating tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const apiKey = request.headers.get("x-api-key") || searchParams.get("apiKey");
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const validation = GetDiscoveryTasksQuerySchema.safeParse({
|
||||
status: searchParams.get("status") || undefined,
|
||||
limit: searchParams.get("limit") || "10",
|
||||
offset: searchParams.get("offset") || "0",
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { status, limit, offset } = validation.data;
|
||||
|
||||
let whereClause = {};
|
||||
if (status) {
|
||||
whereClause = Array.isArray(status) ? { status: { in: status } } : { status };
|
||||
}
|
||||
|
||||
const tasks = await prisma.projectDiscoveryTask.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const total = await prisma.projectDiscoveryTask.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tasks,
|
||||
total,
|
||||
hasMore: offset + tasks.length < total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error fetching tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+2
-4
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next"
|
||||
import { VercelMetrics } from "./VercelMetrics"
|
||||
import { UmamiMetrics } from "./UmamiMetrics"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -7,8 +7,6 @@ export const metadata: Metadata = {
|
||||
description: "发现和探索全网优质 AI 项目",
|
||||
}
|
||||
|
||||
const isVercelProduction = process.env.VERCEL_ENV === "production"
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -18,7 +16,7 @@ export default function RootLayout({
|
||||
<html lang="zh" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
{isVercelProduction ? <VercelMetrics /> : null}
|
||||
<UmamiMetrics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+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>;
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ProjectInputSchema } from "./validations";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BatchResetTasksSchema,
|
||||
CreateDiscoveryTaskSchema,
|
||||
GetDiscoveryTasksQuerySchema,
|
||||
ProjectInputSchema,
|
||||
} from "./validations";
|
||||
|
||||
describe("ProjectInputSchema", () => {
|
||||
const baseProjectInput = {
|
||||
@@ -30,4 +35,33 @@ describe("ProjectInputSchema", () => {
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should default discovery task sourceType to manual", () => {
|
||||
const parsed = CreateDiscoveryTaskSchema.parse({
|
||||
apiKey: "k".repeat(32),
|
||||
tasks: [{ sourceUrl: "https://github.com/example/repo" }],
|
||||
});
|
||||
|
||||
expect(parsed.tasks[0]?.sourceType).toBe("manual");
|
||||
});
|
||||
|
||||
it("should parse comma-separated discovery statuses", () => {
|
||||
const parsed = GetDiscoveryTasksQuerySchema.parse({
|
||||
status: "PENDING,FAILED",
|
||||
limit: "20",
|
||||
offset: "5",
|
||||
});
|
||||
|
||||
expect(parsed.status).toEqual(["PENDING", "FAILED"]);
|
||||
expect(parsed.limit).toBe(20);
|
||||
expect(parsed.offset).toBe(5);
|
||||
});
|
||||
|
||||
it("should reject batch reset without taskIds or statuses", () => {
|
||||
expect(() =>
|
||||
BatchResetTasksSchema.parse({
|
||||
apiKey: "k".repeat(32),
|
||||
})
|
||||
).toThrow("必须提供 taskIds 或 statuses 之一");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,72 @@ export const WebhookAuthSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
});
|
||||
|
||||
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
|
||||
|
||||
const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(JsonValueSchema),
|
||||
z.record(JsonValueSchema),
|
||||
])
|
||||
);
|
||||
|
||||
export const JsonObjectSchema = z.record(JsonValueSchema);
|
||||
|
||||
export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
|
||||
tasks: z
|
||||
.array(
|
||||
z.object({
|
||||
sourceUrl: z.string().url().max(2000),
|
||||
sourceType: z.string().max(50).default("manual"),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const UpdateDiscoveryTaskSchema = z.object({
|
||||
apiKey: z.string().min(32),
|
||||
status: TaskStatusEnum,
|
||||
explorationData: JsonObjectSchema.optional(),
|
||||
explorationSummary: z.string().max(1000).optional(),
|
||||
errorMessage: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const GetDiscoveryTasksQuerySchema = z.object({
|
||||
status: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const statuses = val
|
||||
.split(",")
|
||||
.map((status) => status.trim() as TaskStatus)
|
||||
.filter(Boolean);
|
||||
|
||||
return statuses.length === 1 ? statuses[0] : statuses;
|
||||
}),
|
||||
limit: z.coerce.number().int().positive().max(100).default(10),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
export const BatchResetTasksSchema = WebhookAuthSchema.extend({
|
||||
taskIds: z.array(z.string()).optional(),
|
||||
statuses: z.array(TaskStatusEnum).optional(),
|
||||
}).refine((data) => data.taskIds || data.statuses, {
|
||||
message: "必须提供 taskIds 或 statuses 之一",
|
||||
});
|
||||
|
||||
export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
|
||||
urls: z.array(z.string().url().max(2000)).min(1).max(100),
|
||||
sourceType: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Signals Schemas
|
||||
// ================================
|
||||
@@ -140,6 +206,12 @@ export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
||||
export type Tag = z.infer<typeof TagSchema>;
|
||||
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
||||
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>;
|
||||
export type TaskStatus = z.infer<typeof TaskStatusEnum>;
|
||||
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
|
||||
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
|
||||
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
|
||||
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
|
||||
export type JsonObject = z.infer<typeof JsonObjectSchema>;
|
||||
export type SignalSource = z.infer<typeof SignalSourceEnum>;
|
||||
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
|
||||
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
|
||||
|
||||
+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