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 (
+ -
+
+
+ {formatDate(project.createdAt, locale)}
+
+
+ {displayName}
+
+
+ {displayDescription}
+
+ {project.tags.length > 0 && (
+
+ {project.tags.slice(0, 3).map((tag) => (
+
+ {locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+ ) : (
+ {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,
+ }
+}