Files
zhuiguang-ai/src/app/HomePageClient.tsx
T

460 lines
21 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
import StatsSummary from "@/components/common/StatsSummary";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
import Image from "next/image";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
}
interface TrendingTool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { name: string; slug: string } | null;
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface HomePageClientProps {
flatCategories: FlatCategory[];
parentCategories: ParentCategory[];
totalTools: number;
totalSkills: number;
trendingTools: TrendingTool[];
}
export default function HomePageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
trendingTools,
}: HomePageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
colorScheme="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
<StatsSummary />
{trendingTools.length > 0 && (
<section className="mb-10">
<div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200/60 px-3 py-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-sm font-semibold text-amber-700">近期热门</span>
</div>
<span className="text-xs text-muted-foreground">最近加入 · 最受关注</span>
</div>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{trendingTools.map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.slug}`}
className="group relative flex h-full min-h-[150px] flex-col overflow-hidden rounded-xl border border-border/60 bg-white p-4 transition-all duration-300 hover:border-amber-200 hover:shadow-lg hover:shadow-amber-500/5 hover:-translate-y-0.5"
>
<div className="absolute top-0 right-0 w-16 h-16 bg-gradient-to-bl from-amber-100/40 to-transparent rounded-bl-2xl" />
<div className="relative flex flex-1 flex-col">
<div className="flex items-center gap-2.5 mb-2.5">
{tool.logoUrl && (
<Image src={tool.logoUrl} referrerPolicy="no-referrer" alt="" width={32} height={32} className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5 bg-white" onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
const fallback = target.nextElementSibling as HTMLElement | null;
if (fallback) fallback.classList.remove("hidden");
}} />
)}
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/40 text-[10px] font-bold text-muted-foreground ${tool.logoUrl ? "hidden" : ""}`}>
{tool.name.slice(0, 2)}
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate group-hover:text-primary transition-colors">{tool.name}</h3>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">{tool.category.name}</span>
)}
</div>
</div>
<p className="flex-1 text-xs text-muted-foreground/70 line-clamp-2 leading-relaxed">{tool.description}</p>
<div className="mt-2.5 flex items-center gap-2">
<span className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium border ${PRICING_COLOR[tool.pricingModel] || "border-slate-200 bg-slate-50 text-slate-600"}`}>
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
<span className="text-[10px] text-muted-foreground/50">{tool.viewCount} 次浏览</span>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.slug}`}
className="group relative flex h-full min-h-[120px] flex-col"
>
<Card className="flex h-full flex-col border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]">
<div className="absolute top-1.5 right-1.5 z-10" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); } }} onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="flex flex-1 flex-col p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<div className="relative h-10 w-10 shrink-0">
<Image
src={tool.logoUrl}
alt={tool.name}
fill
sizes="40px"
className="rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = parent.nextElementSibling as HTMLElement;
if (fallback) fallback.classList.remove('hidden');
}
}}
/>
</div>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-medium ${PRICING_COLOR[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{PRICING_LABEL[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 flex-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Link
href={`/categories/${activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
查看全部 {displayTools.length} 个 →
</Link>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}