460 lines
21 KiB
TypeScript
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>
|
|
);
|
|
} |