diff --git a/docs/n8n/project-tag-reset-workflow.json b/docs/n8n/project-tag-reset-workflow.json index 84effa6..07f8e12 100644 --- a/docs/n8n/project-tag-reset-workflow.json +++ b/docs/n8n/project-tag-reset-workflow.json @@ -4,42 +4,92 @@ { "parameters": {}, "id": "manual-trigger", - "name": "Manual Trigger", + "name": "手动触发", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, - "position": [0, 0] + "position": [ + 0, + 0 + ] }, { "parameters": { - "url": "={{$env.SITE_BASE_URL}}/api/tags", + "url": "https://www.agentpark.fun/api/tags", "options": {} }, "id": "fetch-tags", - "name": "Fetch Tags", + "name": "获取标签池", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, - "position": [220, 0] + "position": [ + 220, + 0 + ] }, { "parameters": { - "url": "={{$env.SITE_BASE_URL}}/api/projects?limit=100&page=1&sort=latest", - "options": {} + "url": "https://www.agentpark.fun/api/projects", + "sendQuery": true, + "specifyQuery": "keypair", + "queryParameters": { + "parameters": [ + { + "name": "limit", + "value": "100" + }, + { + "name": "page", + "value": "1" + }, + { + "name": "sort", + "value": "latest" + } + ] + }, + "options": { + "pagination": { + "pagination": { + "paginationMode": "updateAParameterInEachRequest", + "parameters": { + "parameters": [ + { + "type": "qs", + "name": "page", + "value": "={{($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) + 1}}" + } + ] + }, + "paginationCompleteWhen": "other", + "completeExpression": "={{!$response.body.pagination || (($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) >= ($response.body.pagination.totalPages ?? 1))}}", + "limitPagesFetched": true, + "maxRequests": 20, + "requestInterval": 0 + } + } + } }, "id": "fetch-projects", - "name": "Fetch Projects", + "name": "获取项目列表", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, - "position": [440, 0] + "position": [ + 440, + 0 + ] }, { "parameters": { - "jsCode": "const tagsResponse = $('Fetch Tags').first().json;\nconst projectsResponse = $input.first().json;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!Array.isArray(projectsResponse?.projects)) {\n throw new Error('Failed to fetch projects from /api/projects');\n}\n\nif ((projectsResponse?.pagination?.total || 0) > projectsResponse.projects.length) {\n throw new Error(`Only fetched ${projectsResponse.projects.length} projects but total is ${projectsResponse.pagination.total}. Increase API limit or add pagination in workflow.`);\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn projectsResponse.projects.map((project) => ({\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n}));" + "jsCode": "const tagsResponse = $('获取标签池').first().json;\nconst project = $input.first().json.projects;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!project || typeof project !== 'object' || !project.slug) {\n throw new Error('Invalid project item after split');\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn [\n {\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n },\n];" }, "id": "prepare-classification-items", - "name": "Prepare Classification Items", + "name": "准备分类输入", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [660, 0] + "position": [ + 660, + 0 + ] }, { "parameters": { @@ -61,10 +111,13 @@ } }, "id": "ai-fixed-project-type", - "name": "AI Fixed Project Type", + "name": "AI 固定项目分类", "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 1.8, - "position": [880, 0] + "position": [ + 880, + 0 + ] }, { "parameters": { @@ -86,10 +139,13 @@ } }, "id": "ai-domain-scenario", - "name": "AI Domain Scenario", + "name": "AI 领域场景分类", "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 1.8, - "position": [1100, 0] + "position": [ + 1100, + 0 + ] }, { "parameters": { @@ -111,10 +167,13 @@ } }, "id": "ai-product-form", - "name": "AI Product Form", + "name": "AI 产品形态分类", "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 1.8, - "position": [1320, 0] + "position": [ + 1320, + 0 + ] }, { "parameters": { @@ -136,10 +195,13 @@ } }, "id": "ai-tech-stack", - "name": "AI Tech Stack", + "name": "AI 技术栈分类", "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 1.8, - "position": [1540, 0] + "position": [ + 1540, + 0 + ] }, { "parameters": { @@ -161,80 +223,207 @@ } }, "id": "ai-ai-paradigm", - "name": "AI AI Paradigm", + "name": "AI 技术范式分类", "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 1.8, - "position": [1760, 0] + "position": [ + 1760, + 0 + ] }, { "parameters": { - "jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('Prepare Classification Items', 0);\nconst fixedItems = $items('AI Fixed Project Type', 0);\nconst domainItems = $items('AI Domain Scenario', 0);\nconst productItems = $items('AI Product Form', 0);\nconst techItems = $items('AI Tech Stack', 0);\nconst paradigmItems = $items('AI AI Paradigm', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;" + "jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('准备分类输入', 0);\nconst fixedItems = $items('AI 固定项目分类', 0);\nconst domainItems = $items('AI 领域场景分类', 0);\nconst productItems = $items('AI 产品形态分类', 0);\nconst techItems = $items('AI 技术栈分类', 0);\nconst paradigmItems = $items('AI 技术范式分类', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;" }, "id": "build-reset-payload", - "name": "Build Reset Payload", + "name": "组装重置载荷", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [1980, 0] + "position": [ + 1980, + 0 + ] }, { "parameters": { "method": "POST", - "url": "={{$env.SITE_BASE_URL}}/api/tags/reset-projects", + "url": "https://www.agentpark.fun/api/tags/reset-projects", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"dryRun\": false,\n \"replaceAllCategories\": true,\n \"projects\": [\n {\n \"projectSlug\": \"{{$json.projectSlug}}\",\n \"selectedTagSlugsByCategory\": {{JSON.stringify($json.selectedTagSlugsByCategory)}}\n }\n ]\n}", "options": {} }, "id": "reset-project-tags", - "name": "Reset Project Tags", + "name": "调用标签重置接口", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, - "position": [2200, 0] + "position": [ + 2200, + 0 + ] }, { "parameters": { "jsCode": "const results = $input.all().map((item) => item.json);\nconst summary = {\n totalRequests: results.length,\n successRequests: results.filter((r) => r.success === true).length,\n failedRequests: results.filter((r) => r.success !== true).length,\n timestamp: new Date().toISOString(),\n};\n\nreturn [{ json: { summary, results } }];" }, "id": "summarize-results", - "name": "Summarize Results", + "name": "汇总执行结果", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [2420, 0] + "position": [ + 2420, + 0 + ] + }, + { + "parameters": { + "operation": "splitOutItems", + "fieldToSplitOut": "projects", + "include": "noOtherFields", + "options": {} + }, + "id": "split-projects", + "name": "拆分项目列表", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3.1, + "position": [ + 560, + 0 + ] } ], "connections": { - "Manual Trigger": { - "main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]] + "手动触发": { + "main": [ + [ + { + "node": "获取标签池", + "type": "main", + "index": 0 + } + ] + ] }, - "Fetch Tags": { - "main": [[{ "node": "Fetch Projects", "type": "main", "index": 0 }]] + "获取标签池": { + "main": [ + [ + { + "node": "获取项目列表", + "type": "main", + "index": 0 + } + ] + ] }, - "Fetch Projects": { - "main": [[{ "node": "Prepare Classification Items", "type": "main", "index": 0 }]] + "获取项目列表": { + "main": [ + [ + { + "node": "拆分项目列表", + "type": "main", + "index": 0 + } + ] + ] }, - "Prepare Classification Items": { - "main": [[{ "node": "AI Fixed Project Type", "type": "main", "index": 0 }]] + "准备分类输入": { + "main": [ + [ + { + "node": "AI 固定项目分类", + "type": "main", + "index": 0 + } + ] + ] }, - "AI Fixed Project Type": { - "main": [[{ "node": "AI Domain Scenario", "type": "main", "index": 0 }]] + "AI 固定项目分类": { + "main": [ + [ + { + "node": "AI 领域场景分类", + "type": "main", + "index": 0 + } + ] + ] }, - "AI Domain Scenario": { - "main": [[{ "node": "AI Product Form", "type": "main", "index": 0 }]] + "AI 领域场景分类": { + "main": [ + [ + { + "node": "AI 产品形态分类", + "type": "main", + "index": 0 + } + ] + ] }, - "AI Product Form": { - "main": [[{ "node": "AI Tech Stack", "type": "main", "index": 0 }]] + "AI 产品形态分类": { + "main": [ + [ + { + "node": "AI 技术栈分类", + "type": "main", + "index": 0 + } + ] + ] }, - "AI Tech Stack": { - "main": [[{ "node": "AI AI Paradigm", "type": "main", "index": 0 }]] + "AI 技术栈分类": { + "main": [ + [ + { + "node": "AI 技术范式分类", + "type": "main", + "index": 0 + } + ] + ] }, - "AI AI Paradigm": { - "main": [[{ "node": "Build Reset Payload", "type": "main", "index": 0 }]] + "AI 技术范式分类": { + "main": [ + [ + { + "node": "组装重置载荷", + "type": "main", + "index": 0 + } + ] + ] }, - "Build Reset Payload": { - "main": [[{ "node": "Reset Project Tags", "type": "main", "index": 0 }]] + "组装重置载荷": { + "main": [ + [ + { + "node": "调用标签重置接口", + "type": "main", + "index": 0 + } + ] + ] }, - "Reset Project Tags": { - "main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]] + "调用标签重置接口": { + "main": [ + [ + { + "node": "汇总执行结果", + "type": "main", + "index": 0 + } + ] + ] + }, + "拆分项目列表": { + "main": [ + [ + { + "node": "准备分类输入", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": { diff --git a/docs/n8n/project-tag-reset-workflow.md b/docs/n8n/project-tag-reset-workflow.md index 83417c2..0b53a1e 100644 --- a/docs/n8n/project-tag-reset-workflow.md +++ b/docs/n8n/project-tag-reset-workflow.md @@ -19,22 +19,34 @@ Each AI node must select only from its own category pool. ## Required Environment Variables in n8n -- `SITE_BASE_URL`: e.g. `https://your-site.com` - `WEBHOOK_API_KEY`: same key configured on Next.js server +## Fixed Base URL + +- All workflow API URLs are hardcoded to `https://www.agentpark.fun` + ## Execution Flow -1. `Manual Trigger` -2. `HTTP GET /api/tags` to load full tag pool -3. `HTTP GET /api/projects?limit=100&page=1` to load projects -4. `Code` prepares per-project classification items and category pools -5. Five independent AI nodes classify by category -6. `Code` normalizes/parses AI outputs into `selectedTagSlugsByCategory` -7. `HTTP POST /api/tags/reset-projects` updates tags per project -8. `Code` summarizes success/failure counts +1. `手动触发` +2. `获取标签池` (`HTTP GET /api/tags`) 读取标签池 +3. `获取项目列表` (`HTTP GET /api/projects`) 使用 Query 参数(`limit=100,page=1,sort=latest`)+ HTTP 节点内置分页拉取全部项目 +4. `拆分项目列表` 将每页 `projects[]` 拆分为“单项目一条记录” +5. `准备分类输入` 仅做轻量字段整理(不再负责分页/HTTP 请求) +6. 五个独立 AI 节点分别分类: + - `AI 固定项目分类` + - `AI 领域场景分类` + - `AI 产品形态分类` + - `AI 技术栈分类` + - `AI 技术范式分类` +7. `组装重置载荷` 归一化 AI 输出并生成 `selectedTagSlugsByCategory` +8. `调用标签重置接口` (`HTTP POST /api/tags/reset-projects`) 回写标签 +9. `汇总执行结果` 输出成功/失败统计 ## Notes -- Current workflow fetches up to 100 projects in one run. If total > 100, add pagination or increase API limit strategy. +- Workflow uses HTTP node built-in pagination for `/api/projects` (`limit=100`) to process full project volume. +- `page` 不再写死在 URL 上,避免出现重复请求同一页导致的 identical response 停止问题。 +- Pagination and project fan-out no longer rely on complex Code-node network logic. +- Workflow no longer depends on `SITE_BASE_URL`; base URL is fixed to `https://www.agentpark.fun`. - Endpoint supports `dryRun`. You can set `"dryRun": true` first in the request node for safe validation. - Endpoint performs category validation and rejects cross-category slug usage. diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 8265486..53c81ff 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,7 +1,12 @@ import { getTranslations } from 'next-intl/server' import { getProjects } from '@/hooks/useProjects' +import { getHomePageData } from '@/hooks/useHome' import { ProjectList } from '@/components/project/ProjectList' import { HomeSearchBar } from '@/components/search/HomeSearchBar' +import { HomeOverviewStats } from '@/components/home/HomeOverviewStats' +import { HomeRankings } from '@/components/home/HomeRankings' +import { HomeTagInsights } from '@/components/home/HomeTagInsights' +import { HomeRecentTimeline } from '@/components/home/HomeRecentTimeline' import { Suspense } from 'react' interface HomePageProps { @@ -13,7 +18,7 @@ export default async function HomePage({ params }: HomePageProps) { const t = await getTranslations('home') const tCommon = await getTranslations('common') - const projectsData = await getProjects({ limit: 6 }) + const [projectsData, homeData] = await Promise.all([getProjects({ limit: 6 }), getHomePageData()]) return ( <> @@ -43,6 +48,65 @@ export default async function HomePage({ params }: HomePageProps) { + {/* Home Insights */} +
+ + + + + + + +
+ {/* Featured Projects Section */}
diff --git a/src/components/home/HomeOverviewStats.tsx b/src/components/home/HomeOverviewStats.tsx new file mode 100644 index 0000000..e8e3496 --- /dev/null +++ b/src/components/home/HomeOverviewStats.tsx @@ -0,0 +1,57 @@ +interface HomeOverviewStatsProps { + locale: string + stats: { + totalProjects: number + activeProjects: number + archivedProjects: number + totalTags: number + newProjects7d: number + } + translations: { + sectionTitle: string + totalProjects: string + activeProjects: string + archivedProjects: string + totalTags: string + newProjects7d: string + } +} + +function formatNumber(value: number, locale: string): string { + const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN') + return formatter.format(value) +} + +export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewStatsProps) { + const cards = [ + { label: translations.totalProjects, value: stats.totalProjects }, + { label: translations.activeProjects, value: stats.activeProjects }, + { label: translations.archivedProjects, value: stats.archivedProjects }, + { label: translations.totalTags, value: stats.totalTags }, + { label: translations.newProjects7d, value: stats.newProjects7d }, + ] + + return ( +
+
+

{translations.sectionTitle}

+
+ +
+ {cards.map((card) => ( +
+

+ {card.label} +

+

+ {formatNumber(card.value, locale)} +

+
+ ))} +
+
+ ) +} diff --git a/src/components/home/HomeRankings.tsx b/src/components/home/HomeRankings.tsx new file mode 100644 index 0000000..b50a54f --- /dev/null +++ b/src/components/home/HomeRankings.tsx @@ -0,0 +1,188 @@ +'use client' + +import Link from 'next/link' +import { useMemo, useState } from 'react' +import type { HomeProjectSummary } from '@/hooks/useHome' + +type RankingWindow = '24h' | '7d' | '30d' + +interface HomeRankingsProps { + locale: string + latestByWindow: Record + topStars: HomeProjectSummary[] + translations: { + sectionTitle: string + latestTitle: string + starsTitle: string + window24h: string + window7d: string + window30d: string + noLatestProjects: string + noStarsProjects: string + viewAllLatest: string + viewAllStars: string + } +} + +function formatDate(date: string, locale: string): string { + const dateObj = new Date(date) + const formatter = new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + return formatter.format(dateObj) +} + +function formatNumber(value: number, locale: string): string { + const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN') + return formatter.format(value) +} + +function ProjectRow({ + project, + locale, + rank, + showStars, +}: { + project: HomeProjectSummary + locale: string + rank: number + showStars: boolean +}) { + const name = locale === 'en' && project.nameEn ? project.nameEn : project.name + + return ( +
  • +
    +
    + {rank} +
    + + {name} + +

    + {formatDate(project.createdAt, locale)} +

    +
    +
    + {showStars && ( + + ★ {formatNumber(project.githubStars, locale)} + + )} +
    +
  • + ) +} + +export function HomeRankings({ + locale, + latestByWindow, + topStars, + translations, +}: HomeRankingsProps) { + const [activeWindow, setActiveWindow] = useState('7d') + + const windowOptions = useMemo( + () => [ + { key: '24h' as const, label: translations.window24h }, + { key: '7d' as const, label: translations.window7d }, + { key: '30d' as const, label: translations.window30d }, + ], + [translations.window24h, translations.window7d, translations.window30d] + ) + + const latestProjects = latestByWindow[activeWindow] + + return ( +
    +
    +

    {translations.sectionTitle}

    +
    + +
    +
    +
    +

    {translations.latestTitle}

    +
    + {windowOptions.map((option) => ( + + ))} +
    +
    + + {latestProjects.length > 0 ? ( +
      + {latestProjects.map((project, index) => ( + + ))} +
    + ) : ( +

    {translations.noLatestProjects}

    + )} + +
    + + {translations.viewAllLatest} + +
    +
    + +
    +
    +

    {translations.starsTitle}

    +
    + + {topStars.length > 0 ? ( +
      + {topStars.map((project, index) => ( + + ))} +
    + ) : ( +

    {translations.noStarsProjects}

    + )} + +
    + + {translations.viewAllStars} + +
    +
    +
    +
    + ) +} diff --git a/src/components/home/HomeRecentTimeline.tsx b/src/components/home/HomeRecentTimeline.tsx new file mode 100644 index 0000000..a874f35 --- /dev/null +++ b/src/components/home/HomeRecentTimeline.tsx @@ -0,0 +1,93 @@ +import Link from 'next/link' +import type { HomeProjectSummary } from '@/hooks/useHome' + +interface HomeRecentTimelineProps { + locale: string + projects: HomeProjectSummary[] + translations: { + sectionTitle: string + emptyText: string + viewAllProjects: string + } +} + +function formatDate(date: string, locale: string): string { + const formatter = new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + + return formatter.format(new Date(date)) +} + +export function HomeRecentTimeline({ + locale, + projects, + translations, +}: HomeRecentTimelineProps) { + return ( +
    +
    +

    {translations.sectionTitle}

    +
    + +
    + {projects.length > 0 ? ( +
      + {projects.map((project) => { + const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name + const displayDescription = + locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description + + return ( +
    1. +
    2. + ) + })} +
    + ) : ( +

    {translations.emptyText}

    + )} + +
    + + {translations.viewAllProjects} + +
    +
    +
    + ) +} diff --git a/src/components/home/HomeTagInsights.tsx b/src/components/home/HomeTagInsights.tsx new file mode 100644 index 0000000..1d035fb --- /dev/null +++ b/src/components/home/HomeTagInsights.tsx @@ -0,0 +1,125 @@ +import Link from 'next/link' +import type { HomeCategoryDistribution } from '@/hooks/useHome' + +interface HomeTagInsightsProps { + locale: string + topTags: Array<{ + id: string + name: string + nameEn: string | null + slug: string + projectCount: number + }> + categories: HomeCategoryDistribution[] + translations: { + sectionTitle: string + topTagsTitle: string + categoryDistributionTitle: string + tagsUnit: string + projectsUnit: string + noTagData: string + viewAllProjects: string + } +} + +function formatNumber(value: number, locale: string): string { + const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN') + return formatter.format(value) +} + +export function HomeTagInsights({ + locale, + topTags, + categories, + translations, +}: HomeTagInsightsProps) { + const maxAssociations = Math.max(1, ...categories.map((category) => category.projectAssociationCount)) + + return ( +
    +
    +

    {translations.sectionTitle}

    +
    + +
    +
    +

    {translations.topTagsTitle}

    + {topTags.length > 0 ? ( +
    + {topTags.map((tag) => ( + + {locale === 'en' && tag.nameEn ? tag.nameEn : tag.name} + + {formatNumber(tag.projectCount, locale)} + + + ))} +
    + ) : ( +

    {translations.noTagData}

    + )} + +
    + + {translations.viewAllProjects} + +
    +
    + +
    +

    {translations.categoryDistributionTitle}

    + {categories.length > 0 ? ( +
    + {categories.map((category) => { + const categoryName = + locale === 'en' ? category.nameEn : category.name + const widthPercent = Math.max( + 6, + Math.round((category.projectAssociationCount / maxAssociations) * 100) + ) + + return ( +
    +
    +

    {categoryName}

    +

    + {formatNumber(category.tagCount, locale)} {translations.tagsUnit} ·{' '} + {formatNumber(category.projectAssociationCount, locale)} {translations.projectsUnit} +

    +
    +
    + + {category.topTag && ( + + {locale === 'en' && category.topTag.nameEn + ? category.topTag.nameEn + : category.topTag.name} + + )} +
    + ) + })} +
    + ) : ( +

    {translations.noTagData}

    + )} +
    +
    +
    + ) +} diff --git a/src/hooks/useHome.ts b/src/hooks/useHome.ts new file mode 100644 index 0000000..9572d43 --- /dev/null +++ b/src/hooks/useHome.ts @@ -0,0 +1,261 @@ +import { prisma } from '@/lib/prisma' +import { getTagCategoryGroups, getTopTags } from '@/hooks/useProjects' +import { Prisma } from '@prisma/client' + +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_RANKING_LIMIT = 6 +const DEFAULT_TIMELINE_LIMIT = 8 +const DEFAULT_TOP_TAG_LIMIT = 12 + +export type HomeProjectSummary = { + id: string + slug: string + name: string + nameEn: string | null + description: string + descriptionEn: string | null + githubStars: number + createdAt: string + tags: Array<{ + id: string + name: string + nameEn: string | null + slug: string + }> +} + +export type HomeCategoryDistribution = { + category: string + name: string + nameEn: string + tagCount: number + projectAssociationCount: number + topTag: { + id: string + name: string + nameEn: string | null + slug: string + projectCount: number + } | null +} + +export type HomePageData = { + overview: { + totalProjects: number + activeProjects: number + archivedProjects: number + totalTags: number + newProjects7d: number + } + rankings: { + latestByWindow: { + '24h': HomeProjectSummary[] + '7d': HomeProjectSummary[] + '30d': HomeProjectSummary[] + } + topStars: HomeProjectSummary[] + } + tagInsights: { + topTags: Array<{ + id: string + name: string + nameEn: string | null + slug: string + projectCount: number + }> + categoryDistribution: HomeCategoryDistribution[] + } + timeline: HomeProjectSummary[] +} + +type ProjectWithRelations = Prisma.ProjectGetPayload<{ + include: { + tags: { + include: { + tag: true + } + } + } +}> + +async function safeQuery(operationName: string, fallback: T, task: () => Promise): Promise { + try { + return await task() + } catch (error) { + console.error( + `[db] ${operationName} degraded to fallback:`, + error instanceof Error ? error.message : String(error) + ) + return fallback + } +} + +function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary { + return { + id: project.id, + slug: project.slug, + name: project.name, + nameEn: project.nameEn, + description: project.description, + descriptionEn: project.descriptionEn, + githubStars: project.githubStars, + createdAt: project.createdAt.toISOString(), + tags: project.tags.map((projectTag) => ({ + id: projectTag.tag.id, + name: projectTag.tag.name, + nameEn: projectTag.tag.nameEn, + slug: projectTag.tag.slug, + })), + } +} + +async function getLatestProjects(limit: number, createdAfter?: Date): Promise { + const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () => + prisma.project.findMany({ + where: { + status: 'ACTIVE', + ...(createdAfter ? { createdAt: { gte: createdAfter } } : {}), + }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: limit, + }) + ) + + return projects.map(mapProjectSummary) +} + +async function getTopStarsProjects(limit: number): Promise { + const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () => + prisma.project.findMany({ + where: { + status: 'ACTIVE', + }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }], + take: limit, + }) + ) + + return projects.map(mapProjectSummary) +} + +export async function getHomePageData(): Promise { + const now = Date.now() + const last24Hours = new Date(now - ONE_DAY_MS) + const last7Days = new Date(now - ONE_DAY_MS * 7) + const last30Days = new Date(now - ONE_DAY_MS * 30) + + const [ + totalProjects, + activeProjects, + archivedProjects, + totalTags, + newProjects7d, + latest24h, + latest7d, + latest30d, + topStars, + timeline, + topTags, + tagCategoryGroups, + ] = await Promise.all([ + safeQuery('countTotalProjects', 0, () => prisma.project.count()), + safeQuery('countActiveProjects', 0, () => prisma.project.count({ where: { status: 'ACTIVE' } })), + safeQuery('countArchivedProjects', 0, () => prisma.project.count({ where: { status: 'ARCHIVED' } })), + safeQuery('countTotalTags', 0, () => + prisma.tag.count({ + where: { + category: { + not: 'FIXED_PROJECT_TYPE', + }, + projects: { + some: {}, + }, + }, + }) + ), + safeQuery('countNewProjects7d', 0, () => + prisma.project.count({ + where: { + status: 'ACTIVE', + createdAt: { + gte: last7Days, + }, + }, + }) + ), + getLatestProjects(DEFAULT_RANKING_LIMIT, last24Hours), + getLatestProjects(DEFAULT_RANKING_LIMIT, last7Days), + getLatestProjects(DEFAULT_RANKING_LIMIT, last30Days), + getTopStarsProjects(DEFAULT_RANKING_LIMIT), + getLatestProjects(DEFAULT_TIMELINE_LIMIT), + getTopTags(DEFAULT_TOP_TAG_LIMIT), + getTagCategoryGroups(), + ]) + + const categoryDistribution: HomeCategoryDistribution[] = tagCategoryGroups.map((group) => { + const projectAssociationCount = group.tags.reduce((sum, tag) => sum + tag._count.projects, 0) + const leadingTag = group.tags[0] + + return { + category: group.category, + name: group.name, + nameEn: group.nameEn, + tagCount: group.tags.length, + projectAssociationCount, + topTag: leadingTag + ? { + id: leadingTag.id, + name: leadingTag.name, + nameEn: leadingTag.nameEn, + slug: leadingTag.slug, + projectCount: leadingTag._count.projects, + } + : null, + } + }) + + return { + overview: { + totalProjects, + activeProjects, + archivedProjects, + totalTags, + newProjects7d, + }, + rankings: { + latestByWindow: { + '24h': latest24h, + '7d': latest7d, + '30d': latest30d, + }, + topStars, + }, + tagInsights: { + topTags: topTags.map((tag) => ({ + id: tag.id, + name: tag.name, + nameEn: tag.nameEn, + slug: tag.slug, + projectCount: tag._count.projects, + })), + categoryDistribution, + }, + timeline, + } +}