refactor: 调整首页统计与标签洞察展示
This commit is contained in:
@@ -56,10 +56,9 @@ export default async function HomePage({ params }: HomePageProps) {
|
||||
translations={{
|
||||
sectionTitle: t('insightsSection'),
|
||||
totalProjects: t('statsTotalProjects'),
|
||||
activeProjects: t('statsActiveProjects'),
|
||||
archivedProjects: t('statsArchivedProjects'),
|
||||
totalTags: t('statsTotalTags'),
|
||||
newProjects30d: t('statsNewProjects30d'),
|
||||
newProjects7d: t('statsNewProjects7d'),
|
||||
newProjects24h: t('statsNewProjects24h'),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -84,13 +83,8 @@ export default async function HomePage({ params }: HomePageProps) {
|
||||
<HomeTagInsights
|
||||
locale={locale}
|
||||
topTags={homeData.tagInsights.topTags}
|
||||
categories={homeData.tagInsights.categoryDistribution}
|
||||
translations={{
|
||||
sectionTitle: t('tagInsightsSection'),
|
||||
topTagsTitle: t('topTagsTitle'),
|
||||
categoryDistributionTitle: t('categoryDistributionTitle'),
|
||||
tagsUnit: t('tagsUnit'),
|
||||
projectsUnit: t('projectsUnit'),
|
||||
noTagData: t('noTagData'),
|
||||
viewAllProjects: t('viewAllProjects'),
|
||||
}}
|
||||
|
||||
@@ -124,4 +124,59 @@
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.tag-marquee::before,
|
||||
.tag-marquee::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2rem;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tag-marquee::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, rgba(245, 242, 235, 1), rgba(245, 242, 235, 0));
|
||||
}
|
||||
|
||||
.tag-marquee::after {
|
||||
right: 0;
|
||||
background: linear-gradient(to left, rgba(245, 242, 235, 1), rgba(245, 242, 235, 0));
|
||||
}
|
||||
|
||||
.dark .tag-marquee::before {
|
||||
background: linear-gradient(to right, rgba(30, 30, 30, 1), rgba(30, 30, 30, 0));
|
||||
}
|
||||
|
||||
.dark .tag-marquee::after {
|
||||
background: linear-gradient(to left, rgba(30, 30, 30, 1), rgba(30, 30, 30, 0));
|
||||
}
|
||||
|
||||
.tag-marquee-track {
|
||||
animation-name: tag-marquee-x;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.tag-marquee:hover .tag-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tag-marquee-x {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tag-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ interface HomeOverviewStatsProps {
|
||||
locale: string
|
||||
stats: {
|
||||
totalProjects: number
|
||||
activeProjects: number
|
||||
archivedProjects: number
|
||||
totalTags: number
|
||||
newProjects30d: number
|
||||
newProjects7d: number
|
||||
newProjects24h: number
|
||||
}
|
||||
translations: {
|
||||
sectionTitle: string
|
||||
totalProjects: string
|
||||
activeProjects: string
|
||||
archivedProjects: string
|
||||
totalTags: string
|
||||
newProjects30d: string
|
||||
newProjects7d: string
|
||||
newProjects24h: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +23,9 @@ function formatNumber(value: number, locale: string): string {
|
||||
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.newProjects30d, value: stats.newProjects30d },
|
||||
{ label: translations.newProjects7d, value: stats.newProjects7d },
|
||||
{ label: translations.newProjects24h, value: stats.newProjects24h },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -37,7 +34,7 @@ export function HomeOverviewStats({ locale, stats, translations }: HomeOverviewS
|
||||
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<article
|
||||
key={card.label}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Star } from 'lucide-react'
|
||||
import type { HomeProjectSummary } from '@/hooks/useHome'
|
||||
|
||||
type RankingWindow = '24h' | '7d' | '30d'
|
||||
@@ -70,8 +71,12 @@ function ProjectRow({
|
||||
</div>
|
||||
</div>
|
||||
{showStars && (
|
||||
<span className="font-display text-xs font-bold shrink-0">
|
||||
★ {formatNumber(project.githubStars, locale)}
|
||||
<span className="font-display text-xs font-bold shrink-0 inline-flex items-center gap-1.5">
|
||||
<Star
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-primary fill-current drop-shadow-[1px_1px_0_rgba(0,0,0,1)]"
|
||||
/>
|
||||
{formatNumber(project.githubStars, locale)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,13 +90,13 @@ export function HomeRankings({
|
||||
topStars,
|
||||
translations,
|
||||
}: HomeRankingsProps) {
|
||||
const [activeWindow, setActiveWindow] = useState<RankingWindow>('7d')
|
||||
const [activeWindow, setActiveWindow] = useState<RankingWindow>('30d')
|
||||
|
||||
const windowOptions = useMemo(
|
||||
() => [
|
||||
{ key: '24h' as const, label: translations.window24h },
|
||||
{ key: '7d' as const, label: translations.window7d },
|
||||
{ key: '30d' as const, label: translations.window30d },
|
||||
{ key: '7d' as const, label: translations.window7d },
|
||||
{ key: '24h' as const, label: translations.window24h },
|
||||
],
|
||||
[translations.window24h, translations.window7d, translations.window30d]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from 'next/link'
|
||||
import type { HomeCategoryDistribution } from '@/hooks/useHome'
|
||||
|
||||
interface HomeTagInsightsProps {
|
||||
locale: string
|
||||
@@ -10,13 +9,8 @@ interface HomeTagInsightsProps {
|
||||
slug: string
|
||||
projectCount: number
|
||||
}>
|
||||
categories: HomeCategoryDistribution[]
|
||||
translations: {
|
||||
sectionTitle: string
|
||||
topTagsTitle: string
|
||||
categoryDistributionTitle: string
|
||||
tagsUnit: string
|
||||
projectsUnit: string
|
||||
noTagData: string
|
||||
viewAllProjects: string
|
||||
}
|
||||
@@ -30,10 +24,10 @@ function formatNumber(value: number, locale: string): string {
|
||||
export function HomeTagInsights({
|
||||
locale,
|
||||
topTags,
|
||||
categories,
|
||||
translations,
|
||||
}: HomeTagInsightsProps) {
|
||||
const maxAssociations = Math.max(1, ...categories.map((category) => category.projectAssociationCount))
|
||||
const marqueeItems = [...topTags, ...topTags]
|
||||
const animationDurationSeconds = Math.max(18, topTags.length * 4)
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -41,16 +35,18 @@ export function HomeTagInsights({
|
||||
<h2 className="font-display text-3xl font-bold">{translations.sectionTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
|
||||
<h3 className="font-display text-xl font-bold mb-4">{translations.topTagsTitle}</h3>
|
||||
{topTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topTags.map((tag) => (
|
||||
<div className="tag-marquee relative overflow-hidden">
|
||||
<div
|
||||
className="tag-marquee-track flex w-max items-center gap-2 pr-2"
|
||||
style={{ animationDuration: `${animationDurationSeconds}s` }}
|
||||
>
|
||||
{marqueeItems.map((tag, index) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
key={`${tag.id}-${index}`}
|
||||
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||
className="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 className="ml-2 text-gray-500 dark:text-gray-400">
|
||||
@@ -59,6 +55,7 @@ export function HomeTagInsights({
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noTagData}</p>
|
||||
)}
|
||||
@@ -72,54 +69,6 @@ export function HomeTagInsights({
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="border-2 border-black dark:border-gray-600 bg-surface-light dark:bg-surface-dark p-5 shadow-neo">
|
||||
<h3 className="font-display text-xl font-bold mb-4">{translations.categoryDistributionTitle}</h3>
|
||||
{categories.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryName =
|
||||
locale === 'en' ? category.nameEn : category.name
|
||||
const widthPercent = Math.max(
|
||||
6,
|
||||
Math.round((category.projectAssociationCount / maxAssociations) * 100)
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={category.category} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-display text-sm font-bold uppercase">{categoryName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNumber(category.tagCount, locale)} {translations.tagsUnit} ·{' '}
|
||||
{formatNumber(category.projectAssociationCount, locale)} {translations.projectsUnit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 border-2 border-black dark:border-gray-600 bg-white dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{category.topTag && (
|
||||
<Link
|
||||
href={`/${locale}/projects?tag=${category.topTag.slug}`}
|
||||
className="inline-flex text-xs font-display font-bold hover:underline"
|
||||
>
|
||||
{locale === 'en' && category.topTag.nameEn
|
||||
? category.topTag.nameEn
|
||||
: category.topTag.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{translations.noTagData}</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
+22
-60
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getTagCategoryGroups, getTopTags } from '@/hooks/useProjects'
|
||||
import { getTopTags } from '@/hooks/useProjects'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
@@ -24,28 +24,12 @@ export type HomeProjectSummary = {
|
||||
}>
|
||||
}
|
||||
|
||||
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
|
||||
newProjects30d: number
|
||||
newProjects7d: number
|
||||
newProjects24h: number
|
||||
}
|
||||
rankings: {
|
||||
latestByWindow: {
|
||||
@@ -63,7 +47,6 @@ export type HomePageData = {
|
||||
slug: string
|
||||
projectCount: number
|
||||
}>
|
||||
categoryDistribution: HomeCategoryDistribution[]
|
||||
}
|
||||
timeline: HomeProjectSummary[]
|
||||
}
|
||||
@@ -162,29 +145,23 @@ export async function getHomePageData(): Promise<HomePageData> {
|
||||
|
||||
const [
|
||||
totalProjects,
|
||||
activeProjects,
|
||||
archivedProjects,
|
||||
totalTags,
|
||||
newProjects30d,
|
||||
newProjects7d,
|
||||
newProjects24h,
|
||||
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({
|
||||
safeQuery('countNewProjects30d', 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
category: {
|
||||
not: 'FIXED_PROJECT_TYPE',
|
||||
},
|
||||
projects: {
|
||||
some: {},
|
||||
status: 'ACTIVE',
|
||||
createdAt: {
|
||||
gte: last30Days,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -199,44 +176,30 @@ export async function getHomePageData(): Promise<HomePageData> {
|
||||
},
|
||||
})
|
||||
),
|
||||
safeQuery('countNewProjects24h', 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
createdAt: {
|
||||
gte: last24Hours,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
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,
|
||||
newProjects30d,
|
||||
newProjects7d,
|
||||
newProjects24h,
|
||||
},
|
||||
rankings: {
|
||||
latestByWindow: {
|
||||
@@ -254,7 +217,6 @@ export async function getHomePageData(): Promise<HomePageData> {
|
||||
slug: tag.slug,
|
||||
projectCount: tag._count.projects,
|
||||
})),
|
||||
categoryDistribution,
|
||||
},
|
||||
timeline,
|
||||
}
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
"projectCount": "projects"
|
||||
},
|
||||
"home": {
|
||||
"title": "AI Project Navigator",
|
||||
"title": "Agent Project Navigator",
|
||||
"subtitle": "Discover and explore AI projects",
|
||||
"featuredProjects": "Featured Projects",
|
||||
"browseByTag": "Browse by Tag",
|
||||
"searchPlaceholder": "Search AI projects...",
|
||||
"aiSearchPlaceholder": "Describe what you're looking for, e.g.: AI tools that can generate videos...",
|
||||
"metaTitle": "Agent Park - AI Project Navigator",
|
||||
"metaTitle": "Agent Park - Agent Project Navigator",
|
||||
"metaDescription": "Discover and explore quality AI projects from across the web",
|
||||
"heroTitle": "AI PROJECT",
|
||||
"heroTitle": "AGENT PROJECT",
|
||||
"heroTitle2": "NAVIGATOR",
|
||||
"heroDescription": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
|
||||
"browseByTagsSection": "BROWSE BY TAGS",
|
||||
@@ -41,6 +41,8 @@
|
||||
"statsTotalProjects": "Total Projects",
|
||||
"statsActiveProjects": "Active Projects",
|
||||
"statsArchivedProjects": "Archived Projects",
|
||||
"statsNewProjects30d": "New in 30 Days",
|
||||
"statsNewProjects24h": "New in 24h",
|
||||
"statsTotalTags": "Total Tags",
|
||||
"statsNewProjects7d": "New in 7 Days",
|
||||
"rankingsSection": "Project Rankings",
|
||||
@@ -53,8 +55,8 @@
|
||||
"noStarsProjects": "No stars data available",
|
||||
"viewAllLatest": "View all latest projects",
|
||||
"viewAllByStars": "View all by stars",
|
||||
"tagInsightsSection": "Tag Popularity & Distribution",
|
||||
"topTagsTitle": "Top Tags",
|
||||
"tagInsightsSection": "Top Tags",
|
||||
"topTagsTitle": "Top Tags Carousel",
|
||||
"categoryDistributionTitle": "Category Distribution",
|
||||
"tagsUnit": "tags",
|
||||
"projectsUnit": "projects",
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
"projectCount": "个项目"
|
||||
},
|
||||
"home": {
|
||||
"title": "AI 项目导航",
|
||||
"title": "Agent 项目导航",
|
||||
"subtitle": "发现和探索全网优质 AI 项目",
|
||||
"featuredProjects": "精选项目",
|
||||
"browseByTag": "按标签浏览",
|
||||
"searchPlaceholder": "搜索 AI 项目...",
|
||||
"aiSearchPlaceholder": "描述你想要的项目,如:能生成视频的 AI 工具...",
|
||||
"metaTitle": "Agent Park - AI 项目导航",
|
||||
"metaTitle": "Agent Park - Agent 项目导航",
|
||||
"metaDescription": "发现和探索全网优质 AI 项目",
|
||||
"heroTitle": "AI 项目",
|
||||
"heroTitle": "Agent 项目",
|
||||
"heroTitle2": "导航",
|
||||
"heroDescription": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
|
||||
"browseByTagsSection": "按标签浏览",
|
||||
@@ -41,6 +41,8 @@
|
||||
"statsTotalProjects": "项目总数",
|
||||
"statsActiveProjects": "活跃项目",
|
||||
"statsArchivedProjects": "归档项目",
|
||||
"statsNewProjects30d": "近30天新增",
|
||||
"statsNewProjects24h": "近24小时新增",
|
||||
"statsTotalTags": "标签总数",
|
||||
"statsNewProjects7d": "近7天新增",
|
||||
"rankingsSection": "项目榜单",
|
||||
@@ -53,8 +55,8 @@
|
||||
"noStarsProjects": "暂无可展示的 Stars 数据",
|
||||
"viewAllLatest": "查看全部最新项目",
|
||||
"viewAllByStars": "查看全部 Stars 排序",
|
||||
"tagInsightsSection": "标签热度与分布",
|
||||
"topTagsTitle": "热门标签",
|
||||
"tagInsightsSection": "热门标签",
|
||||
"topTagsTitle": "热门标签轮播",
|
||||
"categoryDistributionTitle": "分类分布",
|
||||
"tagsUnit": "个标签",
|
||||
"projectsUnit": "个项目",
|
||||
|
||||
Reference in New Issue
Block a user