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