feat: 服务器迁移后的全量演进(依赖升级/安全审计/设计统一/截图/SEO 底座化)

在原仓库历史之上同步本服务器上的全部演进:

- 迁移至新服务器并重建 PG 数据库 + ddnc 开发域名绑定
- 全部依赖升级至最新(Next 16.3.5 / React 19.3 / better-auth 1.7.5 / Drizzle 0.45 / TS 7),
  overrides 消除已知 CVE,安全审计 19 项发现全部修复闭环
- 设计统一:各站宽度 token(--container + site-container)、classic hero 重设计、
  头部换行修复、页面过渡与 View Transitions
- 截图功能:api.adx9.com 截图服务接入(ow=800 本地化 webp、cf=1、去重/限流/占位图)
- SEO 底座化:canonical/hreflang/sitemap/JSON-LD/robots 全部收进 core,新站自动继承
- 加载体验:五站五形态路由骨架(data-theme 切换、shimmer 微光、延迟淡入)+
  过滤 useTransition 交叉淡入淡出(card-enter 错峰入场)
- 构建:experimental.cpus 限流防 PG 连接打满;清理死组件链
This commit is contained in:
bigcode
2026-09-15 18:19:53 +08:00
parent 0eed5183b2
commit 05440ffb5b
144 changed files with 3055 additions and 12937 deletions
+1 -1
View File
@@ -9,6 +9,6 @@ export const Container = ({
className?: string;
}) => {
return (
<div className={cn(`max-w-7xl mx-auto px-4 `, className)}>{children}</div>
<div className={cn(`site-container px-4 `, className)}>{children}</div>
);
};
@@ -1,30 +0,0 @@
"use client";
import React from "react";
import { AnimatedTooltip } from "@/components/ui/animated-tooltip";
import { useTranslations } from 'next-intl';
const images = [
"https://images.unsplash.com/photo-1599566150163-29194dcaad36?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3387&q=80",
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8YXZhdGFyfGVufDB8fDB8fHww&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1580489944761-15a19d654956?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NXx8YXZhdGFyfGVufDB8fDB8fHww&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTB8fGF2YXRhcnxlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=800&q=60",
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3540&q=80",
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=3534&q=80",
];
export function FeaturedTestimonials() {
const t = useTranslations('featuredTestimonials');
const people = (t.raw('people') as any[]).map((person, index) => ({
id: index + 1,
name: person.name,
designation: person.designation,
image: images[index],
}));
return (
<div className="flex flex-row items-center justify-center mb-10 w-full">
<AnimatedTooltip items={people} />
</div>
);
}
@@ -1,131 +0,0 @@
"use client";
import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { ToolLogo } from "./tool-logo";
interface ToolData {
id: string;
slug: string;
domain: string | null;
websiteUrl: string | null;
logoUrl: string | null;
nameEn: string;
descriptionEn: string | null;
nameZh: string | null;
descriptionZh: string | null;
}
interface CategoryData {
id: string;
slug: string;
icon: string | null;
nameEn: string;
nameZh: string | null;
toolCount: number;
}
interface FeaturedToolsProps {
tools: ToolData[];
categories: CategoryData[];
}
export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
const locale = useLocale();
const t = useTranslations("featuredTools");
const getName = (item: { nameEn: string; nameZh: string | null }) =>
locale === "zh" && item.nameZh ? item.nameZh : item.nameEn;
const getDescription = (tool: ToolData) =>
locale === "zh" && tool.descriptionZh
? tool.descriptionZh
: tool.descriptionEn;
return (
<div className="relative z-20">
<Heading as="h2">{t("title")}</Heading>
<Subheading className="text-center">{t("subtitle")}</Subheading>
{/* 分类导航 */}
{categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => (
<LocaleLink
key={category.id}
href={`/tools?category=${category.slug}`}
className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium"
>
{category.icon && <span className="mr-1">{category.icon}</span>}
{getName(category)}
<span className="ml-1 text-muted-foreground">
({category.toolCount})
</span>
</LocaleLink>
))}
</div>
)}
{/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-12">
{tools.slice(0, 8).map((tool) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-sm text-muted-foreground truncate">
{tool.domain}
</p>
)}
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
{tool.websiteUrl && (
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{t("visitWebsite")}</span>
</div>
)}
</LocaleLink>
))}
</div>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
{t("empty")}
</div>
)}
{/* 查看更多按钮 */}
{tools.length > 0 && (
<div className="flex justify-center mt-12">
<LocaleLink
href="/tools"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
>
{t("viewAll")}
<ArrowRight className="h-4 w-4" />
</LocaleLink>
</div>
)}
</div>
);
}
-123
View File
@@ -1,123 +0,0 @@
"use client";
import React from "react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { cn } from "@/lib/utils";
import { GridLineHorizontal, GridLineVertical } from "./grid-lines";
import { SkeletonOne } from "./skeletons/first";
import { SkeletonTwo } from "./skeletons/second";
import { SkeletonFour } from "./skeletons/fourth";
import { SkeletonThree } from "./skeletons/third";
import { useTranslations } from 'next-intl';
export const Features = () => {
const t = useTranslations('features');
const features = [
{
title: t('items.aiOptimized.title'),
description: t('items.aiOptimized.description'),
skeleton: <SkeletonOne />,
className:
"col-span-1 md:col-span-4 border-b border-r border-border",
},
{
title: t('items.zeroPipeline.title'),
description: t('items.zeroPipeline.description'),
skeleton: <SkeletonTwo />,
className: "border-b col-span-1 md:col-span-2 border-border",
},
{
title: t('items.nonTechnical.title'),
description: t('items.nonTechnical.description'),
skeleton: <SkeletonThree />,
className: "col-span-1 md:col-span-3 border-r border-border",
},
{
title: t('items.productionGrade.title'),
description: t('items.productionGrade.description'),
skeleton: <SkeletonFour />,
className: "col-span-1 md:col-span-3",
},
];
return (
<div className="relative z-20 py-10 md:py-40">
<Heading as="h2">{t('title')}</Heading>
<Subheading className="text-center ">
{t('subtitle')}
</Subheading>
<div className="relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-6 mt-12">
{features.map((feature) => (
<FeatureCard key={feature.title} className={feature.className}>
<FeatureTitle>{feature.title}</FeatureTitle>
<FeatureDescription>{feature.description}</FeatureDescription>
<div className=" h-full w-full">{feature.skeleton}</div>
</FeatureCard>
))}
</div>
<GridLineHorizontal
style={{
top: 0,
left: "-10%",
width: "120%",
}}
/>
<GridLineHorizontal
style={{
bottom: 0,
left: "-10%",
width: "120%",
}}
/>
<GridLineVertical
style={{
top: "-10%",
right: 0,
height: "120%",
}}
/>
<GridLineVertical
style={{
top: "-10%",
left: 0,
height: "120%",
}}
/>
</div>
</div>
);
};
const FeatureCard = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
return (
<div className={cn(`p-4 sm:p-8 relative overflow-hidden`, className)}>
{children}
</div>
);
};
const FeatureTitle = ({ children }: { children?: React.ReactNode }) => {
return (
<Heading as="h3" size="sm" className="text-left">
{children}
</Heading>
);
};
const FeatureDescription = ({ children }: { children?: React.ReactNode }) => {
return (
<Subheading className="text-left max-w-sm mx-0 md:text-sm my-2">
{children}
</Subheading>
);
};
-52
View File
@@ -1,52 +0,0 @@
"use client";
import createGlobe from "cobe";
import { useEffect, useRef } from "react";
// https://github.com/shuding/cobe
export const Globe = ({ className }: { className?: string }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let phi = 0;
if (!canvasRef.current) return;
const globe = createGlobe(canvasRef.current, {
devicePixelRatio: 2,
width: 600 * 2,
height: 600 * 2,
phi: 0,
theta: 0,
dark: 1,
diffuse: 1.2,
mapSamples: 16000,
mapBrightness: 6,
baseColor: [0.3, 0.3, 0.3],
markerColor: [0.1, 0.8, 1],
glowColor: [1, 1, 1],
markers: [
// longitude latitude
{ location: [37.7595, -122.4367], size: 0.03 },
{ location: [40.7128, -74.006], size: 0.1 },
],
onRender: (state) => {
// Called on every animation frame.
// `state` will be an empty object, return updated params.
state.phi = phi;
phi += 0.01;
},
});
return () => {
globe.destroy();
};
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: 600, height: 600, maxWidth: "100%", aspectRatio: 1 }}
className={className}
/>
);
};
-39
View File
@@ -1,39 +0,0 @@
import { cn } from "@/lib/utils";
import { MotionProps, Transition } from "framer-motion";
import React from "react";
export const Heading = ({
className,
as: Tag = "h2",
children,
size = "md",
...props
}: {
className?: string;
as?: any;
children: any;
size?: "sm" | "md" | "xl" | "2xl";
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
const sizeVariants = {
sm: "text-xl md:text-2xl md:leading-snug",
md: "text-3xl md:text-5xl md:leading-tight",
xl: "text-4xl md:text-6xl md:leading-none",
"2xl": "text-5xl md:text-7xl md:leading-none",
};
return (
<Tag
className={cn(
"text-3xl md:text-5xl md:leading-tight max-w-5xl mx-auto text-center tracking-tight",
"font-medium",
"text-foreground",
sizeVariants[size],
className
)}
{...props}
>
{children}
</Tag>
);
};
@@ -1,50 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { useId } from "react";
import { motion } from "framer-motion";
export const HorizontalGradient = ({
className,
...props
}: {
className: string;
[x: string]: any;
}) => {
const id = useId();
return (
<svg
width="1595"
height="2"
viewBox="0 0 1595 2"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"absolute w-full object-contain pointer-events-none",
className
)}
{...props}
>
<path
d="M0 1H1594.5"
stroke={`url(#line-path-gradient-${id})`}
strokeDasharray="8 8"
/>
<defs>
<linearGradient
id={`line-path-gradient-${id}`}
x1="0"
y1="1.5"
x2="1594.5"
y2="1.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.2" stopColor="var(--neutral-400)" />
<stop offset="0.8" stopColor="var(--neutral-400)" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
};
@@ -1,18 +0,0 @@
"use client";
import { useRef } from "react";
import { useInView } from "framer-motion";
export function InViewDiv({
children,
...props
}: { children: React.ReactNode } & any) {
const containerRef = useRef<HTMLDivElement>(null);
let isInView = useInView(containerRef, { once: true, amount: 0.4 });
return (
<div ref={containerRef} {...props}>
{isInView ? children : null}
</div>
);
}
+67 -7
View File
@@ -1,4 +1,16 @@
/**
* # 函数 serializeJsonLd
* 说明:JSON-LD 序列化 + XSS 防护。JSON.stringify 不转义 "<",
* 用户提交数据(工具名/简介/评论)进入 JSON-LD 时,形如
* "</script><script>...</script>" 的内容可从 script 标签逃逸。
* 将 "<" 统一转义为 \u003c,保证输出始终是安全的 JSON 字面量。
*/
function serializeJsonLd(data: unknown): string {
return JSON.stringify(data).replace(/</g, '\u003c')
}
interface WebSiteJsonLdProps {
name: string
description: string
@@ -31,7 +43,7 @@ export function WebSiteJsonLd({ name, description, url, locale }: WebSiteJsonLdP
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -59,7 +71,7 @@ export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: Organizatio
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -97,7 +109,7 @@ export function CollectionPageJsonLd({
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -162,7 +174,7 @@ export function SoftwareApplicationJsonLd({
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -189,7 +201,7 @@ export function FaqPageJsonLd({ faqs }: FaqPageJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -217,7 +229,55 @@ export function BreadcrumbJsonLd({ items }: BreadcrumbJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
interface BlogPostingJsonLdProps {
headline: string
description?: string
url: string
image?: string
datePublished?: string
dateModified?: string
authorName?: string
locale?: string
}
/**
* BlogPostingJsonLd
* 说明:博客文章 BlogPosting 结构化数据(富结果资格取决于平台,语法有效不保证展示)。
*/
export function BlogPostingJsonLd({
headline,
description,
url,
image,
datePublished,
dateModified,
authorName,
locale,
}: BlogPostingJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline,
...(description ? { description } : {}),
...(url ? { url, mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),
...(image ? { image: [image] } : {}),
...(datePublished ? { datePublished } : {}),
...(dateModified ? { dateModified } : {}),
...(authorName
? { author: { '@type': 'Person', name: authorName } }
: {}),
...(locale ? { inLanguage: locale === 'zh' ? 'zh-CN' : 'en-US' } : {}),
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -250,7 +310,7 @@ export function ItemListJsonLd({ name, url, items }: ItemListJsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }}
/>
)
}
@@ -3,7 +3,8 @@
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner";
import { Share2, Link2, Twitter, Linkedin } from "lucide-react";
import { Share2, Link2 } from "lucide-react";
import { IconBrandX, IconBrandLinkedin } from "@tabler/icons-react";
/**
* # 组件 ShareButton
@@ -67,7 +68,7 @@ export function ShareButton({ toolName }: { toolName: string }) {
aria-label="Share on X"
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
>
<Twitter className="h-3.5 w-3.5" />
<IconBrandX className="h-3.5 w-3.5" />
X
</button>
<button
@@ -80,7 +81,7 @@ export function ShareButton({ toolName }: { toolName: string }) {
aria-label="Share on LinkedIn"
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
>
<Linkedin className="h-3.5 w-3.5" />
<IconBrandLinkedin className="h-3.5 w-3.5" />
LinkedIn
</button>
</div>
@@ -1,113 +0,0 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { BlurImage } from "../blur-image";
export const SkeletonOne = () => {
return (
<div className="relative flex p-8 gap-10 h-full">
<div className=" w-full md:w-[90%] p-5 mx-auto bg-card shadow-2xl group h-full">
<div className="flex flex-1 w-full h-full flex-col space-y-2 opacity-20 dark:opacity-60 ">
<UserMessage>
I want to generate an image of two people, fighting outside a bar.
They fight to the core. Once they&apos;re done, they sit down and
drink beer.
</UserMessage>
<AIMessage>
Certainly, I&apos;m generating this picture for you in a while. BTW
are you talking about THAT movie?
</AIMessage>
<UserMessage>
I don&apos;t know what you&apos;re talking about.
</UserMessage>
<AIMessage>Are you sure?</AIMessage>
<UserMessage>
Yes, I&apos;m sure. But if you&apos;re generating that scene, make
sure the fighters have clown shoes and rubber chickens instead of
fists!
</UserMessage>
<AIMessage>Affirmative, here&apos;s your image.</AIMessage>
</div>
</div>
<div className="flex flex-col gap-4 absolute inset-0">
<div className="p-2 border border-border bg-muted rounded-[32px] r h-[250px] w-[250px] md:h-[300px] md:w-[300px] mx-auto flex-shrink-0 z-20 group-hover:scale-[1.02] transition duration-200">
<div className="p-2 bg-card border border-border rounded-[24px] flex-shrink-0">
<BlurImage
src="/skeleton-one.png"
alt="header"
width={800}
height={800}
className="rounded-[20px] w-full h-full object-cover object-bottom aspect-square flex-shrink-0 grayscale"
/>
</div>
</div>
<div className="p-2 border border-border bg-muted rounded-[32px] r h-[250px] w-[250px] md:h-[300px] md:w-[300px] mx-auto flex-shrink-0 z-20 group-hover:scale-[1.02] transition duration-200">
<div className="p-2 bg-card border border-border rounded-[24px] flex-shrink-0">
<BlurImage
src="/tyler.jpeg"
alt="header"
width={800}
height={800}
className="rounded-[20px] w-full h-full object-cover object-bottom aspect-square flex-shrink-0 grayscale"
/>
</div>
</div>
</div>
<div className="absolute bottom-0 z-40 inset-x-0 h-60 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none" />
<div className="absolute top-0 z-40 inset-x-0 h-60 bg-gradient-to-b from-background via-transparent to-transparent w-full pointer-events-none" />
</div>
);
};
const UserMessage = ({ children }: { children: React.ReactNode }) => {
const variants = {
initial: {
x: 0,
},
animate: {
x: 5,
transition: {
duration: 0.2,
},
},
};
return (
<motion.div
variants={variants}
className="flex flex-row rounded-2xl p-2 items-start space-x-2 bg-card"
>
<Image
src="/avatar.jpeg"
alt="avatar"
height="100"
width="100"
className="rounded-full h-4 w-4 md:h-10 md:w-10"
/>
<p className="text-[10px] sm:text-sm text-muted-foreground">{children}</p>
</motion.div>
);
};
const AIMessage = ({ children }: { children: React.ReactNode }) => {
const variantsSecond = {
initial: {
x: 0,
},
animate: {
x: 10,
transition: {
duration: 0.2,
},
},
};
return (
<motion.div
variants={variantsSecond}
className="flex flex-row rounded-2xl p-2 items-center justify-start space-x-2 bg-card "
>
<div className="h-4 w-4 md:h-10 md:w-10 rounded-full bg-gradient-to-r from-pink-500 to-violet-500 flex-shrink-0" />
<p className="text-[10px] sm:text-sm text-muted-foreground">{children}</p>
</motion.div>
);
};
@@ -1,274 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils";
import { InfiniteMovingCards } from "@/components/ui/infinite-moving-cards";
import { Globe } from "../globe";
export const SkeletonFour = () => {
return (
<div className="h-full flex flex-col items-center relative bg-background mt-10">
<InfiniteMovingCards speed="fast" direction="left">
<MovingGrid />
</InfiniteMovingCards>
<InfiniteMovingCards speed="slow" direction="right">
<MovingGrid />
</InfiniteMovingCards>
<InfiniteMovingCards speed="normal" direction="left">
<MovingGrid />
</InfiniteMovingCards>
<Globe className="absolute -right-2 md:-right-40 -bottom-40" />
</div>
);
};
const MovingGrid = () => {
return (
<div className="flex space-x-4 flex-shrink-0 mb-4 relative z-40">
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-4 w-4 object-contain"
>
<path
fill="#2396ED"
d="M12.342 4.536l.15-.227.262.159.116.083c.28.216.869.768.996 1.684.223-.04.448-.06.673-.06.534 0 .893.124 1.097.227l.105.057.068.045.191.156-.066.2a2.044 2.044 0 01-.47.73c-.29.299-.8.652-1.609.698l-.178.005h-.148c-.37.977-.867 2.078-1.702 3.066a7.081 7.081 0 01-1.74 1.488 7.941 7.941 0 01-2.549.968c-.644.125-1.298.187-1.953.185-1.45 0-2.73-.288-3.517-.792-.703-.449-1.243-1.182-1.606-2.177a8.25 8.25 0 01-.461-2.83.516.516 0 01.432-.516l.068-.005h10.54l.092-.007.149-.016c.256-.034.646-.11.92-.27-.328-.543-.421-1.178-.268-1.854a3.3 3.3 0 01.3-.81l.108-.187zM2.89 5.784l.04.007a.127.127 0 01.077.082l.006.04v1.315l-.006.041a.127.127 0 01-.078.082l-.039.006H1.478a.124.124 0 01-.117-.088l-.007-.04V5.912l.007-.04a.127.127 0 01.078-.083l.039-.006H2.89zm1.947 0l.039.007a.127.127 0 01.078.082l.006.04v1.315l-.007.041a.127.127 0 01-.078.082l-.039.006H3.424a.125.125 0 01-.117-.088L3.3 7.23V5.913a.13.13 0 01.085-.123l.039-.007h1.413zm1.976 0l.039.007a.127.127 0 01.077.082l.007.04v1.315l-.007.041a.127.127 0 01-.078.082l-.039.006H5.4a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.039-.006h1.413zm1.952 0l.039.007a.127.127 0 01.078.082l.007.04v1.315a.13.13 0 01-.085.123l-.04.006H7.353a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.04-.006h1.412zm1.97 0l.039.007a.127.127 0 01.078.082l.006.04v1.315a.13.13 0 01-.085.123l-.039.006H9.322a.124.124 0 01-.117-.088l-.006-.04V5.912l.006-.04a.127.127 0 01.078-.083l.04-.006h1.411zM4.835 3.892l.04.007a.127.127 0 01.077.081l.007.041v1.315a.13.13 0 01-.085.123l-.039.007H3.424a.125.125 0 01-.117-.09l-.007-.04V4.021a.13.13 0 01.085-.122l.039-.007h1.412zm1.976 0l.04.007a.127.127 0 01.077.081l.007.041v1.315a.13.13 0 01-.085.123l-.039.007H5.4a.125.125 0 01-.117-.09l-.006-.04V4.021l.006-.04a.127.127 0 01.078-.082l.039-.007h1.412zm1.953 0c.054 0 .1.037.117.088l.007.041v1.315a.13.13 0 01-.085.123l-.04.007H7.353a.125.125 0 01-.117-.09l-.006-.04V4.021l.006-.04a.127.127 0 01.078-.082l.04-.007h1.412zm0-1.892c.054 0 .1.037.117.088l.007.04v1.316a.13.13 0 01-.085.123l-.04.006H7.353a.124.124 0 01-.117-.088l-.006-.04V2.128l.006-.04a.127.127 0 01.078-.082L7.353 2h1.412z"
/>
</svg>
<span>Docker</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
className="h-4 w-4 object-contain"
>
<g fill="#0080FF">
<path d="M8 12.208v2.652c4.495 0 7.988-4.34 6.518-9.053a6.642 6.642 0 00-4.333-4.333C5.48.004 1.14 3.504 1.14 7.992h2.652c0-2.815 2.792-4.993 5.756-3.92a3.975 3.975 0 012.372 2.372c1.08 2.961-1.1 5.75-3.912 5.756V9.563H5.363v2.645H8z" />
<path d="M5.363 14.245H3.325v-2.037h2.038v2.037zM1.622 12.208h1.703v-1.704H1.622v1.704z" />
</g>
</svg>
<span>Digital Ocean</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<title>file_type_tailwind</title>
<path
d="M9,13.7q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q11.1,10.9,9,13.7ZM2,22.1q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q4.1,19.3,2,22.1Z"
fill="#44a8b3"
/>
</svg>
<span>Tailwind CSS</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 20.3335H16V27.0002L9 20.3335Z"
fill="#0055FF"
/>
<path d="M16 13.6665H9V20.3332H23L16 13.6665Z" fill="#00AAFF" />
<path d="M9 7L16 13.6667H23V7H9Z" fill="#88DDFF" />
</svg>
<span>Framer Motion</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<svg
width="800px"
height="800px"
viewBox="0 -18 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
className="h-4 w-4 object-contain"
>
<g>
<path
d="M245.969687,168.943256 C232.308259,176.064479 161.536048,205.163388 146.468577,213.017633 C131.402107,220.873879 123.031844,220.797876 111.129473,215.107699 C99.2271007,209.417521 23.9127473,178.99557 10.3463234,172.511368 C3.56511141,169.270267 0,166.535181 0,163.9511 L0,138.075292 C0,138.075292 98.0490639,116.729625 113.878559,111.051447 C129.707053,105.372269 135.199225,105.167264 148.669646,110.101418 C162.141067,115.036572 242.686583,129.569026 256,134.445178 C256,134.445178 255.993999,157.5559 255.993999,159.954975 C255.996,162.513055 252.923904,165.319143 245.969687,168.943256"
fill="#912626"
></path>
<path
d="M245.964922,143.220067 C232.303935,150.33806 161.534003,179.438032 146.467017,187.292024 C131.401031,195.149018 123.031039,195.072017 111.12905,189.382023 C99.2260618,183.696028 23.9151336,153.269057 10.3491466,146.788063 C-3.21684053,140.303069 -3.50184026,135.840074 9.82514705,130.622079 C23.1511343,125.402084 98.0490629,96.0171117 113.880047,90.3381172 C129.708033,84.6611226 135.199028,84.4541228 148.669014,89.3901181 C162.140002,94.3241134 232.487935,122.325087 245.799922,127.200082 C259.11491,132.081078 259.625908,136.099073 245.964922,143.220067"
fill="#C6302B"
></path>
<path
d="M245.969687,127.074354 C232.308259,134.195577 161.536048,163.294486 146.468577,171.151732 C131.402107,179.004977 123.031844,178.928975 111.129473,173.238797 C99.2261007,167.551619 23.9127473,137.126668 10.3463234,130.642465 C3.56511141,127.401364 0,124.669279 0,122.085199 L0,96.2063895 C0,96.2063895 98.0490639,74.8617226 113.878559,69.182545 C129.707053,63.5043676 135.199225,63.2983612 148.669646,68.2325154 C162.141067,73.1676697 242.686583,87.6971237 256,92.5742761 C256,92.5742761 255.993999,115.684998 255.993999,118.087073 C255.996,120.644153 252.923904,123.450241 245.969687,127.074354"
fill="#912626"
></path>
<path
d="M245.964922,101.351164 C232.303935,108.471157 161.534003,137.569129 146.467017,145.426122 C131.401031,153.280114 123.031039,153.203114 111.12905,147.51312 C99.2260618,141.827125 23.9151336,111.401154 10.3491466,104.91916 C-3.21684053,98.4361664 -3.50184026,93.9721706 9.82514705,88.7521756 C23.1511343,83.5351806 98.0490629,54.1482087 113.880047,48.4702141 C129.708033,42.7922195 135.199028,42.5862197 148.669014,47.521215 C162.140002,52.4552102 232.487935,80.4541835 245.799922,85.3311789 C259.11491,90.2101742 259.625908,94.2301704 245.964922,101.350163 L245.964922,101.351164"
fill="#C6302B"
></path>
<path
d="M245.969687,83.6525661 C232.308259,90.7737887 161.536048,119.873698 146.468577,127.730944 C131.402107,135.585189 123.031844,135.508187 111.129473,129.818008 C99.2261007,124.130831 23.9127473,93.7048802 10.3463234,87.2226777 C3.56511141,83.9805764 0,81.2474909 0,78.6654102 L0,52.7856015 C0,52.7856015 98.0490639,31.4419345 113.878559,25.7637571 C129.707053,20.0845797 135.199225,19.8795733 148.669646,24.8137275 C162.141067,29.7488817 242.686583,44.2783357 256,49.1554881 C256,49.1554881 255.993999,72.2662103 255.993999,74.6672853 C255.996,77.2223652 252.923904,80.0284528 245.969687,83.6525661"
fill="#912626"
></path>
<path
d="M245.964922,57.929387 C232.303935,65.0493802 161.534003,94.1493524 146.467017,102.004345 C131.401031,109.858338 123.031039,109.781338 111.12905,104.093343 C99.2270617,98.4053484 23.9151336,67.9813773 10.3491466,61.4983836 C-3.21684053,55.0153898 -3.50184026,50.550394 9.82514705,45.331399 C23.1511343,40.113404 98.0490629,10.729432 113.880047,5.04943744 C129.708033,-0.629557148 135.199028,-0.833556953 148.669014,4.10143834 C162.140002,9.03643363 232.487935,37.0354069 245.799922,41.9124022 C259.11491,46.7883976 259.625908,50.8093938 245.964922,57.929387"
fill="#C6302B"
></path>
<path
d="M159.282977,32.7570853 L137.273922,35.0422326 L132.346419,46.8976124 L124.387597,33.667969 L98.973147,31.383814 L117.936992,24.5452403 L112.247442,14.0472558 L130.001736,20.9910078 L146.739969,15.5108217 L142.21631,26.3660155 L159.282977,32.7570853"
fill="#FFFFFF"
></path>
<path
d="M131.03169,90.2747287 L89.9546047,73.2378295 L148.815752,64.2034109 L131.03169,90.2747287"
fill="#FFFFFF"
></path>
<path
d="M74.0816124,39.3466047 C91.4568682,39.3466047 105.541829,44.8069457 105.541829,51.5413333 C105.541829,58.2767132 91.4568682,63.736062 74.0816124,63.736062 C56.7073488,63.736062 42.6213953,58.2767132 42.6213953,51.5413333 C42.6213953,44.8069457 56.7073488,39.3466047 74.0816124,39.3466047"
fill="#FFFFFF"
></path>
<path
d="M185.29476,35.9977674 L220.130605,49.7642171 L185.324527,63.5167752 L185.29476,35.9977674"
fill="#621B1C"
></path>
<path
d="M146.754853,51.2426667 L185.29476,35.9977674 L185.324527,63.5167752 L181.546047,64.9952248 L146.754853,51.2426667"
fill="#9A2928"
></path>
</g>
</svg>
<span>Redis</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 px-2 py-1 bg-card text-sm rounded-md flex justify-center items-center shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<VercelLogo /> <span>Vercel</span>
</span>
<span
className={cn(
" min-w-24 flex justify-center bg-card items-center px-2 py-1 space-x-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<AWSLogo /> <span>AWS</span>
</span>
<span
className={cn(
" space-x-1 min-w-24 flex justify-center items-center bg-card px-2 py-1 text-sm rounded-md shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)]"
)}
>
<GitHubLogo /> <span>GitHub</span>
</span>
</div>
);
};
// Logos
const GitHubLogo = () => {
return (
<svg
width="800px"
height="800px"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<g
id="Page-1"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g
id="Dribbble-Light-Preview"
transform="translate(-140.000000, -7559.000000)"
fill="#000000"
>
<g id="icons" transform="translate(56.000000, 160.000000)">
<path
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
id="github-[#142]"
></path>
</g>
</g>
</g>
</svg>
);
};
const VercelLogo = () => {
return (
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 object-contain"
>
<path fillRule="evenodd" d="M256,48,496,464H16Z" />
</svg>
);
};
const AWSLogo = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width="48px"
height="48px"
className="h-4 w-4 object-contain"
>
<path
fill="#252f3e"
d="M13.527,21.529c0,0.597,0.064,1.08,0.176,1.435c0.128,0.355,0.287,0.742,0.511,1.161 c0.08,0.129,0.112,0.258,0.112,0.371c0,0.161-0.096,0.322-0.303,0.484l-1.006,0.677c-0.144,0.097-0.287,0.145-0.415,0.145 c-0.16,0-0.319-0.081-0.479-0.226c-0.224-0.242-0.415-0.5-0.575-0.758c-0.16-0.274-0.319-0.58-0.495-0.951 c-1.245,1.483-2.81,2.225-4.694,2.225c-1.341,0-2.411-0.387-3.193-1.161s-1.181-1.806-1.181-3.096c0-1.37,0.479-2.483,1.453-3.321 s2.267-1.258,3.911-1.258c0.543,0,1.102,0.048,1.692,0.129s1.197,0.21,1.836,0.355v-1.177c0-1.225-0.255-2.08-0.75-2.58 c-0.511-0.5-1.373-0.742-2.602-0.742c-0.559,0-1.133,0.064-1.724,0.21c-0.591,0.145-1.165,0.322-1.724,0.548 c-0.255,0.113-0.447,0.177-0.559,0.21c-0.112,0.032-0.192,0.048-0.255,0.048c-0.224,0-0.335-0.161-0.335-0.5v-0.79 c0-0.258,0.032-0.451,0.112-0.564c0.08-0.113,0.224-0.226,0.447-0.339c0.559-0.29,1.229-0.532,2.012-0.726 c0.782-0.21,1.612-0.306,2.49-0.306c1.9,0,3.289,0.435,4.183,1.306c0.878,0.871,1.325,2.193,1.325,3.966v5.224H13.527z M7.045,23.979c0.527,0,1.07-0.097,1.644-0.29c0.575-0.193,1.086-0.548,1.517-1.032c0.255-0.306,0.447-0.645,0.543-1.032 c0.096-0.387,0.16-0.855,0.16-1.403v-0.677c-0.463-0.113-0.958-0.21-1.469-0.274c-0.511-0.064-1.006-0.097-1.501-0.097 c-1.07,0-1.852,0.21-2.379,0.645s-0.782,1.048-0.782,1.854c0,0.758,0.192,1.322,0.591,1.709 C5.752,23.786,6.311,23.979,7.045,23.979z M19.865,25.721c-0.287,0-0.479-0.048-0.607-0.161c-0.128-0.097-0.239-0.322-0.335-0.629 l-3.752-12.463c-0.096-0.322-0.144-0.532-0.144-0.645c0-0.258,0.128-0.403,0.383-0.403h1.565c0.303,0,0.511,0.048,0.623,0.161 c0.128,0.097,0.223,0.322,0.319,0.629l2.682,10.674l2.49-10.674c0.08-0.322,0.176-0.532,0.303-0.629 c0.128-0.097,0.351-0.161,0.639-0.161h1.277c0.303,0,0.511,0.048,0.639,0.161c0.128,0.097,0.239,0.322,0.303,0.629l2.522,10.803 l2.762-10.803c0.096-0.322,0.208-0.532,0.319-0.629c0.128-0.097,0.335-0.161,0.623-0.161h1.485c0.255,0,0.399,0.129,0.399,0.403 c0,0.081-0.016,0.161-0.032,0.258s-0.048,0.226-0.112,0.403l-3.847,12.463c-0.096,0.322-0.208,0.532-0.335,0.629 s-0.335,0.161-0.607,0.161h-1.373c-0.303,0-0.511-0.048-0.639-0.161c-0.128-0.113-0.239-0.322-0.303-0.645l-2.474-10.4 L22.18,24.915c-0.08,0.322-0.176,0.532-0.303,0.645c-0.128,0.113-0.351,0.161-0.639,0.161H19.865z M40.379,26.156 c-0.83,0-1.66-0.097-2.458-0.29c-0.798-0.193-1.421-0.403-1.836-0.645c-0.255-0.145-0.431-0.306-0.495-0.451 c-0.064-0.145-0.096-0.306-0.096-0.451v-0.822c0-0.339,0.128-0.5,0.367-0.5c0.096,0,0.192,0.016,0.287,0.048 c0.096,0.032,0.239,0.097,0.399,0.161c0.543,0.242,1.133,0.435,1.756,0.564c0.639,0.129,1.261,0.193,1.9,0.193 c1.006,0,1.788-0.177,2.331-0.532c0.543-0.355,0.83-0.871,0.83-1.532c0-0.451-0.144-0.822-0.431-1.129 c-0.287-0.306-0.83-0.58-1.612-0.838l-2.315-0.726c-1.165-0.371-2.027-0.919-2.554-1.645c-0.527-0.709-0.798-1.499-0.798-2.338 c0-0.677,0.144-1.274,0.431-1.79s0.671-0.967,1.149-1.322c0.479-0.371,1.022-0.645,1.66-0.838C39.533,11.081,40.203,11,40.906,11 c0.351,0,0.718,0.016,1.07,0.064c0.367,0.048,0.702,0.113,1.038,0.177c0.319,0.081,0.623,0.161,0.91,0.258s0.511,0.193,0.671,0.29 c0.224,0.129,0.383,0.258,0.479,0.403c0.096,0.129,0.144,0.306,0.144,0.532v0.758c0,0.339-0.128,0.516-0.367,0.516 c-0.128,0-0.335-0.064-0.607-0.193c-0.91-0.419-1.932-0.629-3.065-0.629c-0.91,0-1.628,0.145-2.123,0.451 c-0.495,0.306-0.75,0.774-0.75,1.435c0,0.451,0.16,0.838,0.479,1.145c0.319,0.306,0.91,0.613,1.756,0.887l2.267,0.726 c1.149,0.371,1.98,0.887,2.474,1.548s0.734,1.419,0.734,2.257c0,0.693-0.144,1.322-0.415,1.87 c-0.287,0.548-0.671,1.032-1.165,1.419c-0.495,0.403-1.086,0.693-1.772,0.903C41.943,26.043,41.193,26.156,40.379,26.156z"
/>
<path
fill="#f90"
d="M43.396,33.992c-5.252,3.918-12.883,5.998-19.445,5.998c-9.195,0-17.481-3.434-23.739-9.142 c-0.495-0.451-0.048-1.064,0.543-0.709c6.769,3.966,15.118,6.369,23.755,6.369c5.827,0,12.229-1.225,18.119-3.741 C43.508,32.364,44.258,33.347,43.396,33.992z M45.583,31.477c-0.671-0.871-4.438-0.419-6.146-0.21 c-0.511,0.064-0.591-0.387-0.128-0.726c3.001-2.128,7.934-1.516,8.509-0.806c0.575,0.726-0.16,5.708-2.969,8.094 c-0.431,0.371-0.846,0.177-0.655-0.306C44.833,35.927,46.254,32.331,45.583,31.477z"
/>
</svg>
);
};
@@ -1,71 +0,0 @@
"use client";
import { stagger, useAnimate } from "framer-motion";
import React, { useState } from "react";
export const SkeletonTwo = () => {
const [scope, animate] = useAnimate();
const [animating, setAnimating] = useState(false);
const handleAnimation = async () => {
if (animating) return;
setAnimating(true);
await animate(
".message",
{
opacity: [0, 1],
y: [20, 0],
},
{
delay: stagger(0.5),
}
);
setAnimating(false);
};
return (
<div className="relative h-full w-full mt-4">
<div className="absolute bottom-0 inset-x-0 h-40 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none" />
<div className="p-4 border border-border bg-muted rounded-[32px] h-full z-20">
<div className="p-2 bg-card border border-border rounded-[24px] h-full">
<div className="w-20 rounded-full bg-muted/80 mx-auto h-6" />
<div
onMouseEnter={handleAnimation}
ref={scope}
className="content mt-4 w-[90%] mx-auto"
>
<UserMessage>
Hello chat! Give me all the links from this website -
https://ui.aceternity.com
</UserMessage>
<AIMessage>Why don&apos;t you do it yourself?</AIMessage>
<UserMessage>
Umm.. Because I&apos;m paying $20/mo for your services?
</UserMessage>
<AIMessage>You think I work for the money?</AIMessage>
<UserMessage>Who do you think you are?</UserMessage>
<AIMessage>I&apos; batman.</AIMessage>
<AIMessage>
Now Playing <br />{" "}
<span className="italic">Something in the way - Nirvana</span>
</AIMessage>
</div>
</div>
</div>
</div>
);
};
const UserMessage = ({ children }: { children: React.ReactNode }) => {
return (
<div className="message bg-muted text-foreground p-2 sm:p-4 text-[10px] sm:text-xs my-4 rounded-md">
{children}
</div>
);
};
const AIMessage = ({ children }: { children: React.ReactNode }) => {
return (
<div className="message bg-foreground text-background p-2 sm:p-4 text-[10px] sm:text-xs my-4 rounded-md">
{children}
</div>
);
};
@@ -1,59 +0,0 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { IconDots, IconPlus } from "@tabler/icons-react";
import { Switch } from "../switch";
export const SkeletonThree = () => {
return (
<div className="h-full w-full sm:w-[80%] mx-auto bg-card shadow-2xl mt-10 group rounded-md">
<div className="absolute bottom-0 inset-x-0 h-40 bg-gradient-to-t from-background via-background to-transparent w-full pointer-events-none z-[11]" />
<div className="flex flex-1 w-full h-full flex-col space-y-2 ">
<div className="flex justify-between border-b border-border pb-2 p-4">
<p className="text-muted-foreground text-sm font-bold">
Add LLM
</p>
<p className="shadow-derek text-muted-foreground text-sm px-2 py-1 rounded-md flex-shrink-0 flex space-x-1 items-center bg-muted">
<IconPlus className="h-4 w-4 text-muted-foreground" />{" "}
<span>Add</span>
</p>
</div>
<div className="flex flex-col space-y-4 p-4">
<Row title="Groq LLM" updatedAt="23rd March" />
<Row title="OpenAI GPT0" updatedAt="21st March" active />
<Row title="Stable DIffusion" updatedAt="3rd May" />
<Row title="Llama 2" updatedAt="1st April" active />
<Row title="Claude 200k" updatedAt="2nd June" active />
</div>
</div>
</div>
);
};
export const Row = ({
title,
updatedAt,
active = false,
}: {
title: string;
updatedAt: string;
active?: boolean;
}) => {
const [checked, setChecked] = useState(active);
return (
<div className="flex justify-between items-center">
<div className="flex space-x-2 items-center">
<p className="text-muted-foreground text-xs shadow-aceternity bg-muted px-1 py-0.5 rounded-md">
{title}
</p>
<p className="text-muted-foreground text-xs">{updatedAt}</p>
</div>
<div className="flex items-center space-x-1">
<Switch checked={checked} setChecked={setChecked} />
<IconDots className="h-4 w-4 text-muted-foreground" />
</div>
</div>
);
};
@@ -1,28 +0,0 @@
import { cn } from "@/lib/utils";
import { MotionProps, Transition } from "framer-motion";
import React from "react";
export const Subheading = ({
className,
as: Tag = "p",
children,
...props
}: {
className?: string;
as?: any;
children: any;
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
return (
<Tag
className={cn(
"text-sm md:text-base max-w-4xl text-left my-4 mx-auto",
"text-muted-foreground text-center font-normal",
className
)}
>
{children}
</Tag>
);
};
@@ -1,188 +0,0 @@
"use client";
import { useState } from "react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
import { cn } from "@/lib/utils";
import { InViewDiv } from "./in-view-div";
import { useMemo } from "react";
import { TestimonialColumnContainer } from "./testimonial-column-container";
import Image from "next/image";
import { useTranslations } from 'next-intl';
export const Testimonials = () => {
const t = useTranslations('testimonials');
return (
<div className="relative z-20 py-10 md:py-40">
<Heading as="h2">{t('title')}</Heading>
<Subheading className="text-center max-w-lg mx-auto">
{t('subtitle')}
</Subheading>
<TestimonialGrid />
</div>
);
};
interface Testimonial {
name: string;
quote: string;
src: string;
designation?: string;
}
function Testimonial({
name,
quote,
src,
designation,
className,
...props
}: Omit<React.ComponentPropsWithoutRef<"figure">, keyof Testimonial> &
Testimonial) {
let animationDelay = useMemo(() => {
let possibleAnimationDelays = [
"0s",
"0.1s",
"0.2s",
"0.3s",
"0.4s",
"0.5s",
];
return possibleAnimationDelays[
Math.floor(Math.random() * possibleAnimationDelays.length)
];
}, []);
const boxStyle = {};
return (
<figure
className={cn(
"animate-fade-in rounded-3xl bg-card p-8 opacity-0 shadow-derek",
className
)}
style={{
animationDelay,
}}
{...props}
>
<div className="flex flex-col items-start">
<div className="flex gap-2">
<Image
src={src}
width={150}
height={150}
className="h-10 w-10 rounded-full"
alt={name}
/>
<div>
<h3 className="text-sm font-medium text-muted-foreground">
{name}
</h3>
<p className="text-sm font-normal text-muted-foreground">
{designation}
</p>
</div>
</div>
<p className="text-base text-muted-foreground mt-4">
{quote}
</p>
</div>
</figure>
);
}
function TestimonialColumn({
testimonials,
className,
containerClassName,
shift = 0,
}: {
testimonials: Testimonial[];
className?: string;
containerClassName?: (reviewIndex: number) => string;
shift?: number;
}) {
return (
<TestimonialColumnContainer className={cn(className)} shift={shift}>
{testimonials
.concat(testimonials)
.map((testimonial, testimonialIndex) => (
<Testimonial
name={testimonial.name}
quote={testimonial.quote}
src={testimonial.src}
designation={testimonial.designation}
key={testimonialIndex}
className={containerClassName?.(
testimonialIndex % testimonials.length
)}
/>
))}
</TestimonialColumnContainer>
);
}
function splitArray<T>(array: Array<T>, numParts: number) {
let result: Array<Array<T>> = [];
for (let i = 0; i < array.length; i++) {
let index = i % numParts;
if (!result[index]) {
result[index] = [];
}
result[index].push(array[i]);
}
return result;
}
function TestimonialGrid() {
const t = useTranslations('testimonials');
// Get all testimonials from translations
// Using raw to get the array directly
const items = t.raw('items') as Array<{
name: string;
quote: string;
designation: string;
}>;
const testimonials = items.map((item, i) => ({
name: item.name,
quote: item.quote,
designation: item.designation,
src: `https://i.pravatar.cc/150?img=${i + 1}`
}));
let columns = splitArray(testimonials, 3);
let column1 = columns[0];
let column2 = columns[1];
let column3 = splitArray(columns[2], 2);
return (
<InViewDiv className="relative -mx-4 mt-16 grid h-[49rem] max-h-[150vh] grid-cols-1 items-start gap-8 overflow-hidden px-4 sm:mt-20 md:grid-cols-2 lg:grid-cols-3">
<TestimonialColumn
testimonials={[...column1, ...column3.flat(), ...column2]}
containerClassName={(tIndex) =>
cn(
tIndex >= column1.length + column3[0].length && "md:hidden",
tIndex >= column1.length && "lg:hidden"
)
}
shift={10}
/>
<TestimonialColumn
testimonials={[...column2, ...column3[1]]}
className="hidden md:block"
containerClassName={(tIndex) =>
tIndex >= column2.length ? "lg:hidden" : ""
}
shift={15}
/>
<TestimonialColumn
testimonials={column3.flat()}
className="hidden lg:block"
shift={10}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background" />
</InViewDiv>
);
}
+3 -3
View File
@@ -70,7 +70,7 @@ export function ToolCard({
)}
>
{typeof rank === "number" && (
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-[0.85rem] font-bold text-primary-foreground shadow tabular-nums">
<span className="mt-1 hidden w-4 shrink-0 self-start text-right text-xs font-semibold text-muted-foreground tabular-nums sm:block">
{rank}
</span>
)}
@@ -89,7 +89,7 @@ export function ToolCard({
{name}
</h3>
{pricing && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{locale === "zh" ? pricing.zh : pricing.en}
</span>
)}
@@ -147,7 +147,7 @@ export function ToolCardGrid({
className?: string;
}) {
return (
<div className={cn("grid gap-3 md:grid-cols-2", className)}>
<div className={cn("grid gap-3 md:auto-rows-fr md:grid-cols-2", className)}>
{tools.map((tool, index) => (
<ToolCard
key={tool.id}
@@ -0,0 +1,40 @@
/**
* # 组件 ToolGridSkeleton
* 说明:与 /tools 真实工具卡片结构一一对应的加载骨架(logo + 名称/域名 +
* 投票/收藏位 + 两行描述 + 底部访问链接/定价标签),数据到达时版式不跳变。
* 色块使用 .shimmer 微光动画(见 globals.css),比整块 pulse 更细腻;
* 颜色/圆角走主题 token,五个站点自动呈现各自色调。
* 参数:count —— 骨架卡片数量(默认 12,与 /tools 每页条数一致)
*/
export function ToolGridSkeleton({ count = 12 }: { count?: number }) {
return (
<div
aria-hidden
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-5">
<div className="flex items-start gap-4">
<div className="shimmer h-12 w-12 flex-shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2 pt-1">
<div className="shimmer h-4 w-2/3 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
<div className="flex gap-1">
<div className="shimmer h-6 w-10 rounded-md" />
<div className="shimmer h-6 w-6 rounded-md" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-4/5 rounded" />
</div>
<div className="mt-4 flex items-center justify-between">
<div className="shimmer h-3 w-24 rounded" />
<div className="shimmer h-5 w-14 rounded-full" />
</div>
</div>
))}
</div>
);
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { z } from "zod";
const email = z
.string({ required_error: "Please enter email" })
.email("Please enter valid email")
.string({ error: "Please enter email" })
.email({ message: "Please enter valid email" })
.min(1, "Please enter email");
const password = z
.string({ required_error: "Please enter password" })
.string({ error: "Please enter password" })
.min(1, "Please enter password");
export const loginSchema = z.object({
@@ -16,7 +16,7 @@ export const loginSchema = z.object({
export const signupSchema = z.object({
name: z
.string({ required_error: "Please enter your name" })
.string({ error: "Please enter your name" })
.min(1, "Please enter your name"),
email,
password,
+30 -7
View File
@@ -2,7 +2,7 @@
import { db } from "@/lib/db";
import { claim, tool, user as userTable } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { nanoid } from "nanoid";
@@ -17,15 +17,15 @@ const claimSchema = z.object({
toolId: z.string().min(1),
role: z.string().max(80).optional(),
message: z.string().min(10, "MESSAGE_TOO_SHORT").max(2000),
proofUrl: z.string().url().max(500).optional().or(z.literal("")),
proofUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional().or(z.literal("")),
});
/** 认领后 owner 可编辑的字段(白名单,防止越权改核心字段) */
const OWNER_EDITABLE_FIELDS = z.object({
taglineEn: z.string().max(200).optional(),
descriptionEn: z.string().max(4000).optional(),
websiteUrl: z.string().url().max(500).optional(),
logoUrl: z.string().url().max(500).optional(),
websiteUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional(),
logoUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500).optional(),
pricing: z
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
.optional(),
@@ -218,10 +218,33 @@ export async function reviewClaim(
.returning({ toolId: claim.toolId, userId: claim.userId });
if (claimRow && decision === "approved") {
await db
// 不变量: 工具仅可在无属主时被认领——条件更新防过期队列条目覆盖在位属主
const claimed = await db
.update(tool)
.set({ claimedByUserId: claimRow.userId })
.where(eq(tool.id, claimRow.toolId));
.set({ claimedByUserId: claimRow.userId, updatedAt: new Date() })
.where(and(eq(tool.id, claimRow.toolId), isNull(tool.claimedByUserId)))
.returning({ id: tool.id });
if (claimed.length === 0) {
// 已有在位属主: 本次批准按 stale 处理(不覆盖), 本次认领行为回滚为 rejected
await db
.update(claim)
.set({ status: "rejected", adminNote: "ALREADY_CLAIMED" })
.where(eq(claim.id, claimId));
return { success: false as const, error: "ALREADY_CLAIMED" };
}
// 同一工具其余 pending 认领全部置 rejected, 防止过期条目再次覆盖
await db
.update(claim)
.set({ status: "rejected", adminNote: "SUPERSEDED" })
.where(
and(
eq(claim.toolId, claimRow.toolId),
eq(claim.status, "pending"),
ne(claim.id, claimId)
)
);
revalidatePath("/[locale]/tools/[slug]", "page");
}
@@ -29,7 +29,7 @@ interface FormShellProps<TFieldValues extends FieldValues> {
/** 品牌强调色 HSL 三元组 */
accent?: string;
/** 登录布局变体:五种站点五种形态 */
variant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "split";
variant?: "glass" | "gradient" | "editorial" | "terminal" | "minimal" | "plain" | "split";
}
/**
@@ -40,6 +40,7 @@ interface FormShellProps<TFieldValues extends FieldValues> {
* - editorial 报纸头版:衬线居中报头 + 下划线输入 + 方角黑按钮(The AI Ledger)
* - terminal 深色终端窗口 + 等宽输入 + 命令行按钮(DevAI.tools)
* - minimal 纯白极简居中 + 大圆角搜索式输入(findai)
* - plain 嵌入页面的朴素表单(联系页等,无全屏装饰/品牌侧栏)
* - split 经典左右分栏(后备)
*/
export function FormShell<TFieldValues extends FieldValues>({
@@ -233,6 +234,22 @@ export function FormShell<TFieldValues extends FieldValues>({
);
}
/* ================= plain:嵌入页面容器的朴素表单 ================= */
if (variant === "plain") {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-5", className)}>
{headerSlot}
{socialSlot}
{children}
{errorBox}
{submitBtn("w-full rounded-lg bg-primary py-3 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60")}
{footerNode}
</form>
</Form>
);
}
/* ================= 后备:左右分栏 ================= */
return (
<Form {...form}>
@@ -39,10 +39,10 @@ export async function subscribeNewsletter(email: string) {
userId: userId ?? existing.userId,
})
.where(eq(newsletterSubscription.id, existing.id));
// 仅"退订→重新激活"这一状态转换才需要恢复 Resend 侧状态;
// 已激活邮箱的重复提交不再外呼(防匿名计费放大)
await syncResendContact(normalizedEmail, false);
}
// 已订阅用户重复提交同样触发同步——这是此前同步失败后的重试路径,
// 不能提前返回跳过(重新订阅场景也依赖它恢复 Resend 侧状态)
await syncResendContact(normalizedEmail, false);
return { success: true as const };
}
@@ -73,7 +73,7 @@ export function ContactForm() {
description={t('description')}
onSubmit={onSubmit}
submitText={t('form.submitButton')}
className="relative z-20"
variant="plain"
headerSlot={null}
footer={
<div className="flex items-center justify-center space-x-4 pt-4">
@@ -2,18 +2,21 @@ import { z } from "zod";
export const contactSchema = z.object({
name: z
.string({ required_error: "Please enter your name" })
.min(1, "Please enter your name"),
.string({ error: "Please enter your name" })
.min(1, "Please enter your name")
.max(80, "Name is too long"),
email: z
.string({ required_error: "Please enter email" })
.email("Please enter valid email")
.string({ error: "Please enter email" })
.email({ message: "Please enter valid email" })
.min(1, "Please enter email"),
company: z
.string({ required_error: "Please enter your company's name" })
.min(1, "Please enter your company's name"),
.string({ error: "Please enter your company's name" })
.min(1, "Please enter your company's name")
.max(120, "Company name is too long"),
message: z
.string({ required_error: "Please enter your message" })
.min(1, "Please enter your message"),
.string({ error: "Please enter your message" })
.min(1, "Please enter your message")
.max(4000, "Message is too long"),
});
export type ContactInput = z.infer<typeof contactSchema>;
@@ -53,13 +53,13 @@ export function UserMenu() {
<div className="flex items-center gap-2">
<LocaleLink
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="whitespace-nowrap px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('common.actions.signIn')}
</LocaleLink>
<LocaleLink
href="/signup"
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
className="whitespace-nowrap bg-primary text-primary-foreground text-sm px-3 py-1.5 rounded-full hover:opacity-90 transition-opacity"
>
{t('common.actions.signUp')}
</LocaleLink>
@@ -107,7 +107,7 @@ export async function submitReview(
pros: data.pros ?? null,
cons: data.cons ?? null,
useCase: data.useCase ?? null,
status: "published",
})
.where(eq(review.id, reviewId));
} else {
@@ -37,7 +37,7 @@ export function StarRating({ value, onChange, size = "sm", className }: StarRati
className={cn(
sizeClass,
"stroke-[1.8]",
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
filled ? "fill-foreground text-foreground" : "text-muted-foreground/40"
)}
/>
);
@@ -20,10 +20,10 @@ export type SubmissionStatus = "pending" | "approved" | "rejected";
const submissionSchema = z.object({
name: z.string().min(2).max(80),
websiteUrl: z.string().url().max(500),
websiteUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").max(500),
tagline: z.string().max(200).optional(),
description: z.string().min(20, "DESCRIPTION_TOO_SHORT").max(4000),
logoUrl: z.string().url().optional().or(z.literal("")),
logoUrl: z.string().url().refine((u) => u.startsWith("https://"), "仅允许 https 链接").optional().or(z.literal("")),
categoryId: z.string().optional(),
pricing: z
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
@@ -111,6 +111,11 @@ export async function checkSubmissionDuplicate(
websiteUrl: string,
name: string
) {
// 会话门: 该 action ID 随客户端 bundle 公开, 防未认证枚举工具元数据
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return { duplicate: false, duplicates: [] };
}
const dup = await checkDuplicateTool(websiteUrl, name);
return {
duplicate: dup.duplicate,
@@ -175,14 +180,19 @@ export async function getSubmissionsAdmin(
export async function approveSubmission(submissionId: string) {
if (!(await isAdmin())) throw new Error("Unauthorized");
const [sub] = await db
.select()
.from(submission)
.where(eq(submission.id, submissionId))
.limit(1);
if (!sub || sub.status !== "pending") {
// 原子认领审批权: 条件更新(仅 pending 可转移), 防并发双审批产出重复公开工具
const claimed = await db
.update(submission)
.set({ status: "approved" })
.where(and(eq(submission.id, submissionId), eq(submission.status, "pending")))
.returning({ id: submission.id, name: submission.name, domain: submission.domain,
websiteUrl: submission.websiteUrl, logoUrl: submission.logoUrl,
description: submission.description, tagline: submission.tagline,
pricing: submission.pricing, categoryId: submission.categoryId });
if (claimed.length === 0) {
return { success: false as const, error: "INVALID_STATE" };
}
const sub = claimed[0];
// slug 生成 + 去重
let slug = slugify(sub.name) || nanoid(8).toLowerCase();
@@ -218,7 +228,7 @@ export async function approveSubmission(submissionId: string) {
await db
.update(submission)
.set({ status: "approved", approvedToolId: toolId })
.set({ approvedToolId: toolId })
.where(eq(submission.id, submissionId));
revalidateToolPaths();
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState, useTransition } from "react";
import { LocaleLink } from "@/components/locale-link";
import { useSearchParams } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
@@ -8,6 +8,7 @@ import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button";
import { ToolLogo } from "@/components/tool-logo";
import { ToolGridSkeleton } from "@/components/tool-grid-skeleton";
interface ToolData {
id: string;
@@ -93,6 +94,26 @@ export function ToolsList({
const router = useIntlRouter();
const searchParams = useSearchParams();
const [searchInput, setSearchInput] = useState(currentSearch || "");
/** # 状态 isPending
* 说明:过滤/翻页导航进行中(router.push 包在 startTransition 里)——
* 期间保留旧列表(变暗)而不跳路由级骨架屏,避免整页白闪。
**/
const [isPending, startTransition] = useTransition();
/** # 状态 showSkeleton
* 说明:骨架覆盖层是否挂载。请求超过 200ms 才挂载(快响应不闪屏);
* 数据到达后延迟 340ms 再卸载,期间以 opacity 过渡淡出,与
* 新内容(card-enter 错峰入场)形成交叉淡入淡出。
**/
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
if (isPending) {
const t = setTimeout(() => setShowSkeleton(true), 200);
return () => clearTimeout(t);
}
const t = setTimeout(() => setShowSkeleton(false), 340);
return () => clearTimeout(t);
}, [isPending]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -103,7 +124,7 @@ export function ToolsList({
params.delete("search");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handleCategoryChange = (categorySlug: string | null) => {
@@ -114,7 +135,7 @@ export function ToolsList({
params.delete("category");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handlePricingChange = (pricing: string | null) => {
@@ -125,7 +146,7 @@ export function ToolsList({
params.delete("pricing");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
@@ -138,7 +159,7 @@ export function ToolsList({
params.set("usecase", slug);
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handleSortChange = (sort: string) => {
@@ -149,13 +170,13 @@ export function ToolsList({
params.delete("sort");
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
router.push(`/tools?${params.toString()}`);
startTransition(() => router.push(`/tools?${params.toString()}`));
};
const getName = (tool: ToolData) =>
@@ -296,71 +317,93 @@ export function ToolsList({
)}
</div>
{/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain}
{/* 工具网格:过滤时旧列表变暗,骨架屏淡入接管;新数据到达后骨架淡出、卡片错峰入场 */}
<div className="relative">
<div
aria-busy={isPending}
className={`transition-opacity duration-300 ${
isPending ? "pointer-events-none opacity-40" : "opacity-100"
}`}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool, i) => (
<LocaleLink
key={tool.id}
href={`/tools/${tool.slug}`}
className="card-enter group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
style={{ animationDelay: `${(i % 12) * 40}ms` }}
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)}
</h3>
{tool.domain && (
<p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain}
</p>
)}
</div>
<div className="flex items-center gap-1">
<VoteButton toolId={tool.id} initialCount={tool.voteCount ?? 0} />
<FavoriteButton toolId={tool.id} />
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
</div>
<div className="flex items-center gap-1">
<VoteButton toolId={tool.id} initialCount={tool.voteCount ?? 0} />
<FavoriteButton toolId={tool.id} />
</div>
</div>
{getDescription(tool) && (
<p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
{getDescription(tool)}
</p>
)}
<div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? (
<div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span>
<div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? (
<div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span>
</div>
) : (
<span />
)}
{tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
{pricingLabels[tool.pricing]}
</span>
)}
</div>
) : (
<span />
)}
{tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
{pricingLabels[tool.pricing]}
</span>
)}
</div>
</LocaleLink>
))}
</div>
</LocaleLink>
))}
</div>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16">
<p className="text-muted-foreground">
{translations.noTools}
</p>
<button
onClick={() => router.push(`/tools`)}
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
>
{translations.clearFilters}
</button>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16">
<p className="text-muted-foreground">
{translations.noTools}
</p>
<button
onClick={() => startTransition(() => router.push(`/tools`))}
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
>
{translations.clearFilters}
</button>
</div>
)}
</div>
)}
{/* 骨架覆盖层:淡入(skeleton-in) → 数据到达后 opacity 过渡淡出 */}
{showSkeleton && (
<div
aria-hidden
className={`pointer-events-none absolute inset-0 z-10 bg-background transition-opacity duration-300 ${
isPending ? "opacity-100 skeleton-in" : "opacity-0"
}`}
>
<ToolGridSkeleton count={12} />
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
@@ -1,4 +1,4 @@
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
import { Search, ArrowRight } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link";
@@ -62,13 +62,13 @@ async function ClassicHome({ locale }: { locale: string }) {
return (
<div className="bg-background">
{/* Hero */}
<section className="hero-dark border-b border-border">
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 md:py-24">
<h1 className="mx-auto max-w-3xl text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
{/* Hero:亮色排版优先,搜索框是唯一焦点 */}
<section className="border-b border-border">
<div className="site-container px-4 pb-14 pt-16 text-center sm:px-6 md:pb-16 md:pt-24">
<h1 className="mx-auto max-w-3xl text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
{th("title")}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{th("description")}
</p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
@@ -77,39 +77,39 @@ async function ClassicHome({ locale }: { locale: string }) {
<input
name="search"
placeholder={t("searchPlaceholder")}
className="h-12 w-full rounded-full border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
className="h-12 w-full rounded-lg border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
aria-label="Search AI tools"
/>
<button
type="submit"
className="absolute right-1.5 top-1.5 h-9 rounded-full bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
className="absolute right-1.5 top-1.5 h-9 rounded-md bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
{t("searchButton")}
</button>
</div>
</form>
{/* 统计条(深色 Hero 内的白色数字) */}
<div className="mx-auto mt-10 flex max-w-lg items-center justify-center gap-10 border-t border-white/15 pt-6">
{/* 统计条:同行文字,数字用衬线级对比,不做卡片 */}
<dl className="mx-auto mt-10 flex max-w-lg items-start justify-center gap-12">
{[
{ v: "1,000+", l: "AI tools" },
{ v: "14", l: "Categories" },
{ v: "7", l: "Rankings" },
].map((s) => (
<div key={s.l} className="text-center">
<div className="text-2xl font-bold text-white">{s.v}</div>
<div className="text-xs text-white/60">{s.l}</div>
<dd className="text-xl font-semibold text-foreground tabular-nums">{s.v}</dd>
<dt className="mt-0.5 text-xs text-muted-foreground">{s.l}</dt>
</div>
))}
</div>
</dl>
<div className="mt-8 flex flex-wrap items-center justify-center gap-2 text-xs">
<span className="text-white/60">{t("popularLabel")}</span>
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs">
<span className="text-muted-foreground">{t("popularLabel")}</span>
{t.raw("popularSearches").map((q: string) => (
<LocaleLink
key={q}
href={`/tools?search=${q}`}
className="rounded-full bg-white/10 px-2.5 py-1 text-white/80 transition-colors hover:bg-white/20"
className="text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
{q}
</LocaleLink>
@@ -118,7 +118,7 @@ async function ClassicHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-7xl space-y-12 px-4 py-12 sm:px-6">
<div className="site-container space-y-12 px-4 py-12 sm:px-6">
{/* Trending */}
{data.trending.length > 0 && (
<section>
@@ -145,10 +145,9 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={category.id}
href={`/category/${category.slug}`}
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
className="group border-b border-border py-3 transition-colors hover:border-primary/40"
>
<LayoutGrid className="h-4 w-4 text-primary" />
<h3 className="mt-2 truncate text-sm font-medium text-foreground group-hover:text-primary">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary">
{locale === "zh" && category.nameZh
? category.nameZh
: category.nameEn}
@@ -198,7 +197,7 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={uc.id}
href={`/ai-tools-for/${uc.slug}`}
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
className="border-b border-border pb-1 text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
>
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
<span className="ml-1.5 text-muted-foreground tabular-nums">
@@ -224,13 +223,12 @@ async function ClassicHome({ locale }: { locale: string }) {
<LocaleLink
key={col.id}
href={`/best/${col.slug}`}
className="group rounded-xl border border-border p-5 transition-all hover:border-primary/30 hover:shadow-sm"
className="group border-t border-border pt-4"
>
<Award className="h-4 w-4 text-primary" />
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary">
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
</h3>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
</p>
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
@@ -246,26 +244,25 @@ async function ClassicHome({ locale }: { locale: string }) {
{data.latestReviews.length > 0 && (
<section>
<SectionHeader title={t("reviewsTitle")} />
<div className="grid gap-3 md:grid-cols-2">
<div className="divide-y divide-border border-y border-border">
{data.latestReviews.map((review, i) => (
<LocaleLink
key={i}
href={`/tools/${review.toolSlug}#reviews`}
className="rounded-xl border border-border p-4 transition-colors hover:border-primary/30"
className="group block py-3.5"
>
<div className="flex items-center gap-2">
<MessageSquareQuote className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-foreground">
<div className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-foreground">
{review.authorName}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
on {review.toolName}
</span>
<span className="ml-auto text-xs font-semibold text-amber-500 tabular-nums">
<span className="ml-auto font-semibold text-foreground tabular-nums">
{review.rating}/5
</span>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
<p className="mt-1.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground group-hover:text-foreground">
{review.content}
</p>
</LocaleLink>
@@ -274,22 +271,21 @@ async function ClassicHome({ locale }: { locale: string }) {
</section>
)}
{/* Submit CTA */}
<section className="rounded-2xl border border-border bg-secondary/30 p-8 text-center">
<Sparkles className="mx-auto h-5 w-5 text-primary" />
<h2 className="mt-3 text-lg font-semibold text-foreground">
{/* Submit CTA:排版式收尾,不做卡片 */}
<section className="border-t border-border pt-10 text-center">
<h2 className="text-lg font-semibold text-foreground">
Built an AI tool?
</h2>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
<p className="mx-auto mt-1.5 max-w-md text-sm leading-relaxed text-muted-foreground">
Submit it to {brand.name} and get discovered by thousands of users
looking for tools like yours.
</p>
<LocaleLink
href="/submit"
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
className="mt-5 inline-flex items-center gap-1.5 rounded-md bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
<TrendingUp className="h-4 w-4" />
Submit your tool
<ArrowRight className="h-3.5 w-3.5" />
</LocaleLink>
</section>
</div>
@@ -1,4 +1,4 @@
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
import { ArrowRight, Star } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "../shared/section";
@@ -61,7 +61,7 @@ async function EditorialHome({ locale }: { locale: string }) {
<div className="min-h-screen bg-background">
{/* Masthead:黑色报头条(当日日期 + 期号),报纸头版签名 */}
<div className="bg-foreground text-background">
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-2 text-[11px] uppercase tracking-[0.25em] sm:px-6">
<div className="site-container flex items-center justify-between px-4 py-2 text-[11px] uppercase tracking-[0.25em] sm:px-6">
<span>{new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>
<span className="hidden sm:inline">Est. 2026</span>
<span>Daily</span>
@@ -70,8 +70,8 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Masthead */}
<section className="hero-mast border-b border-border">
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:px-6 md:py-20">
<p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
<div className="site-container px-4 py-16 text-center sm:px-6 md:py-20">
<p className="text-sm italic text-muted-foreground">
{t("mastheadLabel")}
</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
@@ -83,14 +83,11 @@ async function EditorialHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-4xl space-y-14 px-4 py-12 sm:px-6">
<div className="site-container space-y-14 px-4 py-12 sm:px-6">
{/* Featured Story */}
{featured && (
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<BookOpen className="h-3.5 w-3.5" />
{t("featuredLabel")}
</div>
<h2 className="mb-4 text-lg font-bold text-foreground">{t("featuredLabel")}</h2>
<LocaleLink href={`/blog/${featured.slug}`} className="group block">
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl">
{featured.title}
@@ -111,10 +108,7 @@ async function EditorialHome({ locale }: { locale: string }) {
{/* Editor's Picks */}
{editorPicks.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
<PenTool className="h-3.5 w-3.5" />
{t("picksEditorialLabel")}
</div>
<h2 className="mb-4 border-t-2 border-foreground/80 pt-4 text-lg font-bold text-foreground">{t("picksEditorialLabel")}</h2>
<div className="divide-y divide-border border-y border-border">
{editorPicks.map((tool, i) => (
<LocaleLink
@@ -135,7 +129,7 @@ async function EditorialHome({ locale }: { locale: string }) {
</div>
{(tool.ratingAvg ?? 0) > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 text-sm font-semibold text-foreground">
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
<Star className="h-3.5 w-3.5 fill-foreground text-foreground" />
{(tool.ratingAvg ?? 0).toFixed(1)}
</span>
)}
@@ -60,8 +60,8 @@ async function MinimalHome({ locale }: { locale: string }) {
<LocaleLink href="/" className="text-3xl font-bold tracking-tight text-foreground">
{brand.name}
</LocaleLink>
<p className="mt-1 text-xs uppercase tracking-[0.35em] text-muted-foreground">
AI tool search
<p className="mt-1.5 text-sm text-muted-foreground">
{brand.tagline.replace(/\.$/, "")}
</p>
<form action={`/${locale}/tools`} className="mt-10 w-full max-w-xl">
@@ -71,19 +71,18 @@ async function MinimalHome({ locale }: { locale: string }) {
name="search"
autoFocus
placeholder={t("minimalPlaceholder")}
className="h-14 w-full rounded-full border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-lg shadow-black/[0.03] placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
className="h-14 w-full rounded-xl border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-sm placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
aria-label="Search AI tools"
/>
</div>
</form>
<div className="mx-auto mt-10 max-w-md border-t border-border/60 pt-5" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
{popularSearches.map((q) => (
<LocaleLink
key={q}
href={`/tools?search=${encodeURIComponent(q)}`}
className="rounded-full px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
className="text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
{q}
</LocaleLink>
@@ -93,19 +92,17 @@ async function MinimalHome({ locale }: { locale: string }) {
{/* 搜索之下:Trending */}
{data.trending.length > 0 && (
<section className="mx-auto w-full max-w-3xl px-4 pb-20 sm:px-6">
<h2 className="mb-4 text-center text-xs font-medium uppercase tracking-[0.25em] text-muted-foreground">
Trending now
</h2>
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
<div className="mt-6 text-center">
<section className="site-container px-4 pb-20 sm:px-6">
<div className="mb-4 flex items-baseline justify-between border-b border-border pb-3">
<h2 className="text-sm font-semibold text-foreground">Trending now</h2>
<LocaleLink
href="/rankings"
className="text-xs font-medium text-muted-foreground hover:text-primary"
className="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{t("seeRankings")}
</LocaleLink>
</div>
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
</section>
)}
</div>
@@ -58,13 +58,13 @@ async function NicheHome({ locale }: { locale: string }) {
<div className="min-h-screen bg-background">
{/* Terminal 风格 Hero + 绿色系统状态条 */}
<div className="border-b border-border bg-background">
<div className="mx-auto flex max-w-5xl items-center gap-2 px-4 py-2 font-mono text-[11px] text-muted-foreground sm:px-6">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
<div className="site-container flex items-center gap-2 px-4 py-2 font-mono text-[11px] text-muted-foreground sm:px-6">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
system: online · 1,000+ tools indexed · updated hourly
</div>
</div>
<section className="border-b border-border bg-[hsl(var(--secondary))]/40">
<div className="mx-auto max-w-5xl px-4 py-14 sm:px-6 md:py-20">
<div className="site-container px-4 py-14 sm:px-6 md:py-20">
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
@@ -97,7 +97,7 @@ async function NicheHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto max-w-5xl space-y-12 px-4 py-12 sm:px-6">
<div className="site-container space-y-12 px-4 py-12 sm:px-6">
{/* Use Cases as workflows */}
{data.useCases.length > 0 && (
<section>
@@ -1,4 +1,4 @@
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { Rocket, ChevronRight, MessageSquare } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { VoteButton } from "@/features/voting/components/vote-button";
import { LocaleLink } from "@/components/locale-link";
@@ -114,18 +114,18 @@ async function ProductHome({ locale }: { locale: string }) {
return (
<div className="min-h-screen bg-background">
{/* Hero:今日发布 */}
<section className="hero-launch border-b border-border bg-gradient-to-b from-primary/5 to-background">
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{/* Hero:亮底排版,橙色只留给主按钮 */}
<section className="border-b border-border">
<div className="site-container px-4 py-12 text-center sm:px-6 md:py-16">
<h1 className="text-balance text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
{th("title")}
</h1>
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{th("description")}
</p>
<LocaleLink
href="/submit"
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow transition-all hover:opacity-90"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
>
<Rocket className="h-4 w-4" />
{t("launchButton")}
@@ -133,14 +133,13 @@ async function ProductHome({ locale }: { locale: string }) {
</div>
</section>
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
<div className="site-container grid gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
{/* 产品 Feed */}
<div>
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3 sm:px-6">
<Flame className="h-4 w-4 text-primary" />
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
</div>
<div className="flex items-center justify-between border-b border-border px-4 py-3 sm:px-6">
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
</div>
{data.trending.length > 0 ? (
data.trending.map((tool, i) => (
<LaunchRow key={tool.id} tool={tool} locale={locale} rank={i + 1} />
@@ -176,8 +175,7 @@ async function ProductHome({ locale }: { locale: string }) {
<aside className="space-y-6">
{data.popular.length > 0 && (
<div className="rounded-2xl border border-border bg-background p-5">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-primary" />
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-foreground">Leaderboard</h3>
</div>
<ol className="mt-4 space-y-3">
+1 -1
View File
@@ -27,7 +27,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
const locale = useLocale();
return (
<footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<div className="site-container px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1">
<Link href={localizedHref("/", locale)} className="flex items-center gap-2">
+6 -6
View File
@@ -69,9 +69,9 @@ export function ThemeHeader({
className
)}
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="site-container flex h-14 items-center justify-between gap-2 px-4 sm:px-6">
{/* Logo */}
<Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2">
<Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{/* 站点 logo(codex 生成的 icon.svg,自带品牌色底,深浅通吃) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" width={28} height={28} />
@@ -87,7 +87,7 @@ export function ThemeHeader({
key={item.href}
href={localizedHref(item.href, locale)}
className={cn(
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
"whitespace-nowrap rounded-lg px-2 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href)
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
@@ -98,17 +98,17 @@ export function ThemeHeader({
))}
</nav>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1">
{/* Desktop 搜索 */}
{showSearch && (
<form onSubmit={submitSearch} className="hidden md:block">
<form onSubmit={submitSearch} className="header-search hidden md:block">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[0.85rem] text-foreground placeholder:text-muted-foreground focus:w-60 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
className="h-8 w-36 whitespace-nowrap rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[0.85rem] text-foreground placeholder:text-muted-foreground focus:w-52 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
aria-label={searchPlaceholder}
/>
</div>
+60 -3
View File
@@ -1,4 +1,6 @@
import { betterAuth } from "better-auth";
import { betterAuth, APIError } from "better-auth";
import { eq } from "drizzle-orm";
import { user } from "./db/schema";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
@@ -10,6 +12,33 @@ const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.filter(Boolean)
: defaultTrustedOrigins;
// 生产环境密钥缺失或为已知默认值时快速失败,避免弱/公开密钥签发会话
const KNOWN_DEFAULT_SECRETS = [
"dev-secret-ai-directory-platform-0123456789abcdef",
"please-change-me-to-a-random-32-char-secret",
"build-time-placeholder-secret-32-chars!!",
];
/**
* # 成员变量:skipEmailVerify
* 安全默认: 仅当显式设置 DEV_SKIP_EMAIL_VERIFY="true" 且非生产环境时跳过
* 邮箱验证; 生产环境无论取值如何都走真实验证流(部署置 false 前会阻塞注册,
* 属预期——生产必须配置真实发信)。
**/
const skipEmailVerify =
process.env.NODE_ENV !== "production" &&
process.env.DEV_SKIP_EMAIL_VERIFY === "true";
if (
process.env.NODE_ENV === "production" &&
(!process.env.BETTER_AUTH_SECRET ||
KNOWN_DEFAULT_SECRETS.includes(process.env.BETTER_AUTH_SECRET))
) {
throw new Error(
"BETTER_AUTH_SECRET is required in production and must not be a known default value"
);
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
@@ -58,12 +87,40 @@ export const auth = betterAuth({
trustedOrigins,
// 开发模式:注册即 emailVerified(生产 DEV_SKIP_EMAIL_VERIFY=false 走真实验证流)
// 限流:按真实客户端 IP(nginx/Cloudflare 转发头)限流认证与写操作
rateLimit: {
enabled: true,
window: 60,
max: 100,
},
advanced: {
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip"],
},
},
// 数据库钩子: (a) 封禁执行——被封禁用户禁止建立新会话(登录), 既有会话由 banUser 删除;
// (b) 开发模式——注册即 emailVerified(生产走真实验证流)
databaseHooks: {
session: {
create: {
before: async (sessionRow) => {
const [u] = await db
.select({ banned: user.banned })
.from(user)
.where(eq(user.id, sessionRow.userId))
.limit(1);
if (u?.banned) {
throw new APIError("FORBIDDEN", { message: "BANNED_USER" });
}
return { data: sessionRow };
},
},
},
user: {
create: {
before: async (userRow) => {
if (process.env.DEV_SKIP_EMAIL_VERIFY !== "false") {
if (skipEmailVerify) {
return { data: { ...userRow, emailVerified: true } };
}
return { data: userRow };
@@ -1,4 +1,5 @@
// This file is auto-generated by scripts/generate-blog-manifest.mjs
/// <reference path="../types/mdx.d.ts" />
// Do not edit manually.
export const blogModuleLoaders = {
+6 -2
View File
@@ -3,6 +3,9 @@ import { defaultLocale } from '@/i18n.config'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
/** 站点品牌名(og:site_name / title template 用),由各站点 env 注入 */
const siteName = process.env.NEXT_PUBLIC_APP_NAME || 'AI Directory'
/**
* 构建与 localePrefix: 'as-needed' 一致的绝对 URL:
* 默认语言 (en) 不带前缀,其他语言带 /{locale} 前缀。
@@ -41,7 +44,8 @@ export function generatePageMetadata({
const canonicalUrl = absoluteUrl(locale, path)
return {
title,
// absolute:子页面传入完整标题,避免被 layout 的 title.template 二次拼接
title: { absolute: title },
description,
alternates: {
canonical: canonicalUrl,
@@ -51,7 +55,7 @@ export function generatePageMetadata({
title,
description,
url: canonicalUrl,
siteName: 'Toolso.AI',
siteName,
images: [
{
url: ogImage,
+56
View File
@@ -0,0 +1,56 @@
/**
* # 模块 rate-limit
* 说明:极简进程内滑动窗口限流(自定义 API/动作用——better-auth 限流
* 只覆盖其托管 handler,不覆盖自定义路由与 server action)。
* 单进程内存实现,多实例部署时应换用共享存储;开发环境足够。
*/
/** # 成员变量:限流桶
* key → 时间戳数组(毫秒);进程生命周期内累积,由清理器防膨胀
**/
const buckets = new Map<string, number[]>();
/** # 成员变量:上次清理时间(防 Map 无界增长) */
let lastSweep = Date.now();
/** # 函数 hit
* 说明:记录一次调用并判断是否超限。超限时不记录(拒绝即不占用配额语义)。
* 参数:key 限流键(调用方拼接维度,如 `ip:${ip}` / `email:${email}`);
* limit 窗口内允许次数;windowMs 窗口毫秒。
* 返回:true=放行(并记录), false=超限拒绝。
*/
export function hit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
sweep(now);
const arr = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (arr.length >= limit) {
buckets.set(key, arr);
return false;
}
arr.push(now);
buckets.set(key, arr);
return true;
}
/** # 函数 sweep
* 说明:偶发清理过期键,防止 Map 无界增长(每 10 分钟一次)。
*/
function sweep(now: number): void {
if (now - lastSweep < 600_000) return;
lastSweep = now;
for (const [k, arr] of buckets) {
const alive = arr.filter((t) => now - t < 3_600_000);
if (alive.length === 0) buckets.delete(k);
else buckets.set(k, alive);
}
}
/** # 函数 clientIp
* 说明:从请求头提取客户端 IP(供限流键使用);置于代理后取第一个转发值。
*/
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
return headers.get("x-real-ip") ?? "unknown";
}
+196
View File
@@ -0,0 +1,196 @@
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
/** # 配置:网站截图服务
* SCREENSHOT_API_BASE: 截图 API 地址(GET https://api.adx9.com/screenshot/v1)
* SCREENSHOT_STORAGE_DIR: 截图本地化存储目录(五站可共享同一目录)
* SCREENSHOT_TIMEOUT_MS: 单次截图请求超时(API 自身导航默认 ~20s,取更宽的客户端上限)
*/
const API_BASE =
process.env.SCREENSHOT_API_BASE || "https://api.adx9.com/screenshot/v1";
const STORAGE_DIR =
process.env.SCREENSHOT_STORAGE_DIR || path.join(process.cwd(), ".screenshots");
const TIMEOUT_MS = Number(process.env.SCREENSHOT_TIMEOUT_MS || 90_000);
/** # 成员变量:截图视口与输出参数
* 视口 1440×900 保持桌面端排版;ow=800 让 API 输出宽度缩到 800 再存储
* (16:10,与详情页展示容器一致,节省体积);cf=1 让带 Cloudflare 5 秒盾的
* 目标站也能正常截图(API 会执行 CF 挑战)
**/
const VIEW = { w: 1440, h: 900, ow: 800, format: "webp", q: 85, wait: 3, cf: 1 } as const;
/** # 成员变量:进行中的截图任务
* 同一工具的并发请求只触发一次 API 调用(首个请求截图,其余等待复用)
**/
const inflight = new Map<string, Promise<Buffer | null>>();
/** # 成员变量:强制刷新冷却表
* ?refresh=1 会触发外部 API 调用(有成本),按 slug 限频防滥用
**/
const forceCooldown = new Map<string, number>();
const FORCE_COOLDOWN_MS = 60_000;
/** # 函数 isForceAllowed
* 说明:该 slug 是否允许立即强制重截;冷却期内返回 false。
*/
export function isForceAllowed(slug: string): boolean {
const last = forceCooldown.get(slug) ?? 0;
const now = Date.now();
if (now - last < FORCE_COOLDOWN_MS) return false;
forceCooldown.set(slug, now);
return true;
}
/** # 函数 buildScreenshotUrl
* 说明:按截图 API 文档拼装 GET 查询串(url 必须 URL 编码)。
* 参数:targetUrl 目标网页;opts 覆盖项(视口/输出/等待),缺省用 VIEW。
* 返回:完整 API URL 字符串。
*/
export function buildScreenshotUrl(
targetUrl: string,
opts?: {
w?: number;
h?: number;
format?: "png" | "jpeg" | "webp";
q?: number;
wait?: number;
ow?: number;
fit?: "contain" | "cover" | "fill";
/** 目标站 Cloudflare 挑战执行(1=让 API 处理 5 秒盾) */
cf?: 0 | 1;
}
): string {
const params = new URLSearchParams({
url: targetUrl,
mode: "viewport",
w: String(opts?.w ?? VIEW.w),
h: String(opts?.h ?? VIEW.h),
wait: String(opts?.wait ?? VIEW.wait),
format: opts?.format ?? VIEW.format,
q: String(opts?.q ?? VIEW.q),
ow: String(opts?.ow ?? VIEW.ow),
cf: String(opts?.cf ?? VIEW.cf),
timeout: "30",
});
if (opts?.fit) params.set("fit", opts.fit);
return `${API_BASE}?${params.toString()}`;
}
/** # 函数 isValidSlug
* 说明:校验工具 slug(防路径穿越,截图只面向工具详情页)。
*/
export function isValidSlug(slug: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/i.test(slug);
}
/** # 函数 screenshotFilePath
* 说明:slug 对应的本地 webp 文件路径(<STORAGE_DIR>/<slug>.webp)。
*/
export function screenshotFilePath(slug: string): string {
return path.join(STORAGE_DIR, `${slug}.webp`);
}
/** # 函数 readLocalScreenshot
* 说明:读取已本地化的截图;不存在返回 null。
* 返回:Buffer | null
*/
export async function readLocalScreenshot(slug: string): Promise<Buffer | null> {
try {
const file = screenshotFilePath(slug);
const info = await stat(file);
if (!info.isFile() || info.size === 0) return null;
const { readFile } = await import("node:fs/promises");
return await readFile(file);
} catch {
return null;
}
}
/** # 函数 hasLocalScreenshot
* 说明:轻量判断截图是否已本地化(供 SSR 渲染分支使用,不读内容)。
*/
export async function hasLocalScreenshot(slug: string): Promise<boolean> {
try {
const info = await stat(screenshotFilePath(slug));
return info.isFile() && info.size > 0;
} catch {
return false;
}
}
/** # 函数 captureScreenshot
* 说明:调用截图 API 并把图片原子化写入本地(临时文件 + rename)。
* API 失败(非 2xx / 返回 JSON 错误)时抛错,由调用方决定降级。
* 参数:force=true 时无视已有文件强制重截。
*/
export async function captureScreenshot(
slug: string,
targetUrl: string,
{ force = false }: { force?: boolean } = {}
): Promise<Buffer | null> {
if (!force && (await hasLocalScreenshot(slug))) {
return readLocalScreenshot(slug);
}
const res = await fetch(buildScreenshotUrl(targetUrl), {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { accept: "image/webp,image/*;q=0.8,*/*;q=0.5" },
});
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.startsWith("image/")) {
const body = await res.text().catch(() => "");
throw new Error(
`screenshot api failed (${res.status} ${type}): ${body.slice(0, 200)}`
);
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0) throw new Error("screenshot api returned empty body");
await mkdir(STORAGE_DIR, { recursive: true });
const finalPath = screenshotFilePath(slug);
const tmpPath = `${finalPath}.${process.pid}.tmp`;
await writeFile(tmpPath, buf);
await rename(tmpPath, finalPath);
return buf;
}
/** # 函数 ensureScreenshot
* 说明:对外入口——本地有就用本地,否则截图并本地化;并发去重。
* 单次失败向上抛错,结果不缓存失败状态(下次请求重试)。
*/
export function ensureScreenshot(
slug: string,
targetUrl: string,
opts?: { force?: boolean }
): Promise<Buffer | null> {
const pending = inflight.get(slug);
if (pending) return pending;
// 非强制路径同样 60s 冷却: 失败/冷 slug 的每个请求都触发计费外呼(负结果冷却),
// 冷却期内直接返回 null(路由降级为占位图)
if (!opts?.force && !isForceAllowed(slug)) {
return Promise.resolve(null);
}
const task = captureScreenshot(slug, targetUrl, opts).finally(() => {
inflight.delete(slug);
});
inflight.set(slug, task);
return task;
}
/** # 函数 placeholderSvg
* 说明:截图不可用时的占位图(中性灰,无装饰),避免详情页出现破图。
* 默认 800×500,与截断输出比例(16:10)一致。
*/
export function placeholderSvg(w = 800, h = 500): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
`<rect width="100%" height="100%" fill="#f4f4f5"/>` +
`<text x="50%" y="50%" text-anchor="middle" fill="#a1a1aa" ` +
`font-family="system-ui,sans-serif" font-size="28">Screenshot unavailable</text>` +
`</svg>`
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { db } from "@/lib/db";
import { tool } from "@/lib/db/schema";
import { eq, or, sql } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { normalizeDomain, slugify } from "@/lib/ranking/score";
export interface DuplicateCheckResult {
@@ -49,7 +49,7 @@ export async function checkDuplicateTool(
domain: tool.domain,
})
.from(tool)
.where(or(...conditions))
.where(and(eq(tool.status, "published"), or(...conditions)))
.limit(5);
return { duplicate: rows.length > 0, matches: rows };
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth/admin";
import { AdminSidebar } from "@/features/admin/components/admin-sidebar";
import { AdminHeader } from "@/features/admin/components/admin-header";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function AdminLayout({
children,
}: {
@@ -14,7 +14,7 @@ import { Input } from "@/features/forms/components/input";
import { toast } from "sonner";
const forgotPasswordSchema = z.object({
email: z.string().email("Please enter a valid email address"),
email: z.string().email({ message: "Please enter a valid email address" }),
});
type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
@@ -1,4 +1,11 @@
import type { Metadata } from "next";
import { AuthLayout } from "@/layouts/auth-layout";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function AuthXLayout({
children,
}: {
@@ -12,7 +12,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'seo' });
return {
title: t('login.title'),
title: { absolute: t('login.title') },
description: t('login.description'),
openGraph: {
images: [t('login.ogImage')],
@@ -12,7 +12,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale, namespace: 'seo' });
return {
title: t('signup.title'),
title: { absolute: t('signup.title') },
description: t('signup.description'),
openGraph: {
images: [t('signup.ogImage')],
@@ -113,7 +113,7 @@ export default async function AiToolsForEntityPage({
});
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<FaqPageJsonLd faqs={seo.faqs} />
<BreadcrumbJsonLd
items={[
@@ -41,7 +41,7 @@ export default async function AiToolsForPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name="AI Tools by Use Case & Audience"
description="Task-oriented and audience-oriented AI tool discovery"
@@ -94,7 +94,7 @@ export default async function BestCollectionPage({
});
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<FaqPageJsonLd faqs={seo.faqs} />
<BreadcrumbJsonLd
items={[
@@ -35,7 +35,7 @@ export default async function BestIndexPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name="Best AI Tools — Curated Lists"
description="Editor-curated best AI tool lists ranked by the community"
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllBlogs, getBlogModule } from "@/lib/blog";
import { BlogPostingJsonLd } from "@/components/json-ld";
import { absoluteUrl } from "@/lib/metadata";
import { locales, type Locale } from "@/i18n.config";
import { generatePageMetadata } from "@/lib/metadata";
@@ -55,5 +57,23 @@ export default async function BlogPostPage({ params }: PageProps) {
const MDXContent = blogModule.default;
return <MDXContent />;
// 文章结构化数据:给搜索引擎明确的标题/作者/日期/配图信号
const { blog } = blogModule;
const articleUrl = absoluteUrl(locale, `/blog/${slug}`);
return (
<>
<BlogPostingJsonLd
headline={blog.title}
description={blog.description}
url={articleUrl}
image={blog.image}
datePublished={blog.date}
dateModified={blog.date}
authorName={blog.author.name}
locale={locale}
/>
<MDXContent />
</>
);
}
@@ -1,9 +1,5 @@
import { type Metadata } from "next";
import { getAllBlogs } from "@/lib/blog";
import { Background } from "@/components/background";
import { Container } from "@/components/container";
import { Heading } from "@/components/heading";
import { Subheading } from "@/components/subheading";
import { BlogCard } from "@/components/blog-card";
import { getTranslations } from 'next-intl/server';
import { type Locale } from '@/i18n.config';
@@ -16,11 +12,12 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'blog' });
const ts = await getTranslations({ locale, namespace: 'seo' });
return generatePageMetadata({
locale,
path: '/blog',
title: t('title'),
title: ts('blog.title'),
description: t('subtitle'),
});
}
@@ -37,28 +34,23 @@ export default async function ArticlesIndex({ params }: PageProps) {
let blogs = await getAllBlogs(locale);
return (
<div className="relative overflow-hidden py-20 md:py-0">
<Background />
<Container className="flex flex-col items-center justify-between pb-20">
<div className="relative z-20 py-10 md:pt-40">
<Heading as="h1">{t('title')}</Heading>
<Subheading className="text-center">
{t('subtitle')}
</Subheading>
</div>
<div className="site-container px-4 py-12 sm:px-6">
{/* 页头 */}
<div className="border-b border-border pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t('title')}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{t('subtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 relative z-20 w-full mb-10">
{blogs.slice(0, 2).map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 w-full relative z-20">
{blogs.slice(2).map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
</Container>
{/* 文章网格 */}
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{blogs.map((blog, index) => (
<BlogCard blog={blog} key={blog.title + index} />
))}
</div>
</div>
);
}
@@ -9,7 +9,7 @@ import {
import { getTranslations, setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n.config";
import { ArrowRight, ExternalLink } from "lucide-react";
import { CollectionPageJsonLd } from "@/components/json-ld";
import { BreadcrumbJsonLd, CollectionPageJsonLd } from "@/components/json-ld";
import { ToolLogo } from "@/components/tool-logo";
import {
FavoritesProvider,
@@ -102,13 +102,20 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
: tool.descriptionEn;
return (
<div className="container mx-auto px-4 pt-24 pb-16">
<div className="site-container px-4 pt-12 pb-16">
<CollectionPageJsonLd
name={name}
description={description || name}
url={absoluteUrl(locale, `/category/${slug}`)}
itemCount={total}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", url: absoluteUrl(locale, "") },
{ name: "AI Tools", url: absoluteUrl(locale, "/tools") },
{ name, url: absoluteUrl(locale, `/category/${slug}`) },
]}
/>
{/* 分类头部 */}
<div className="mx-auto mb-12 max-w-2xl text-center">
@@ -1,8 +1,4 @@
import { Background } from "@/components/background";
import { Metadata } from "next";
import { FeaturedTestimonials } from "@/components/featured-testimonials";
import { cn } from "@/lib/utils";
import { HorizontalGradient } from "@/components/horizontal-gradient";
import { ContactForm } from "@/features/marketing/components/contact-form";
import { getTranslations } from 'next-intl/server';
import type { Locale } from "@/i18n.config";
@@ -15,11 +11,12 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'contact' });
const ts = await getTranslations({ locale, namespace: 'seo' });
return generatePageMetadata({
locale,
path: '/contact',
title: t('title'),
title: ts('contact.title'),
description: t('subtitle'),
});
}
@@ -33,33 +30,20 @@ export default async function ContactPage({
const t = await getTranslations({ locale, namespace: 'contact' });
return (
<div className="relative overflow-hidden py-20 md:py-0 px-4 md:px-20 bg-background">
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2 relative overflow-hidden">
<Background />
<div className="site-container px-4 py-12 sm:px-6">
{/* 页头 */}
<div className="border-b border-border pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t('title')}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{t('subtitle')}
</p>
</div>
{/* 表单:居中栏,与整站容器同框 */}
<div className="mx-auto mt-10 max-w-2xl">
<ContactForm />
<div className="relative w-full z-20 hidden md:flex border-l border-border overflow-hidden bg-background items-center justify-center">
<div className="max-w-sm mx-auto">
<FeaturedTestimonials />
<p
className={cn(
"font-semibold text-xl text-center text-muted-foreground"
)}
>
{t('testimonial.title')}
</p>
<p
className={cn(
"font-normal text-base text-center text-foreground mt-8"
)}
>
{t('testimonial.description')}
</p>
</div>
<HorizontalGradient className="top-20" />
<HorizontalGradient className="bottom-20" />
<HorizontalGradient className="-right-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
<HorizontalGradient className="-left-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
</div>
</div>
</div>
);
@@ -1,20 +1,140 @@
/**
* # 组件 MarketingLoading
* 说明:营销路由组的加载骨架屏 —— 客户端导航时即时反馈,替代"冻结"的旧页面。
* 说明:营销路由组的加载骨架屏(客户端导航时即时反馈,替代"冻结"的旧页面)。
* 五个站点各有一套形态(对应各站卡片设计语言:classic 方角卡片 /
* product 圆角列表行 / editorial 分隔线名录 / niche 浏览器拟物卡片 /
* minimal 细线行),由 <html data-theme> 决定渲染哪一套,CSS 见
* globals.css 的 .loading-variant 规则。
* 整体 .loading-shell 延迟淡入(快速导航不闪屏),色块用 .shimmer 微光。
*/
export default function Loading() {
return (
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
<div className="mx-auto mb-4 h-8 w-64 animate-pulse rounded-lg bg-secondary" />
<div className="mx-auto mb-10 h-4 w-96 max-w-full animate-pulse rounded bg-secondary" />
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-28 animate-pulse rounded-xl border border-border bg-secondary/50"
style={{ animationDelay: `${i * 90}ms` }}
/>
))}
<div className="site-container px-4 py-12 sm:px-6">
<div className="loading-shell mx-auto max-w-5xl">
{/* ai-stack(classic):居中标题 + 三列方角卡片 */}
<div className="loading-variant lv-ai-stack">
<div className="mb-10 space-y-3 text-center">
<div className="shimmer mx-auto h-8 w-64 rounded-lg" />
<div className="shimmer mx-auto h-4 w-96 max-w-full rounded" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-5">
<div className="flex items-start gap-4">
<div className="shimmer h-12 w-12 flex-shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2 pt-1">
<div className="shimmer h-4 w-2/3 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-4/5 rounded" />
</div>
<div className="mt-4 flex items-center justify-between">
<div className="shimmer h-3 w-24 rounded" />
<div className="shimmer h-5 w-14 rounded-full" />
</div>
</div>
))}
</div>
</div>
{/* launchai(product):标题 + 圆角面板内的分隔列表行 */}
<div className="loading-variant lv-launchai">
<div className="mb-10 space-y-3">
<div className="shimmer h-9 w-80 max-w-full rounded-lg" />
<div className="shimmer h-4 w-72 max-w-full rounded" />
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<div className="shimmer h-11 w-full border-b border-border" />
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-border px-4 py-5 last:border-b-0 sm:px-6"
>
<div className="shimmer h-11 w-11 flex-shrink-0 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="shimmer h-4 w-1/3 rounded" />
<div className="shimmer h-3 w-2/3 rounded" />
</div>
<div className="shimmer hidden h-5 w-16 flex-shrink-0 rounded-full sm:block" />
</div>
))}
</div>
</div>
{/* the-ai-ledger(editorial):报头 + 粗顶线分隔名录行 */}
<div className="loading-variant lv-the-ai-ledger">
<div className="mb-10 space-y-4">
<div className="shimmer h-3 w-28 rounded" />
<div className="shimmer h-10 w-full max-w-xl rounded" />
<div className="shimmer h-10 w-2/3 rounded" />
</div>
<div className="divide-y divide-border border-t-2 border-foreground/80">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-4 py-5">
<div className="shimmer h-5 w-5 flex-shrink-0 rounded-sm" />
<div className="flex-1 space-y-2">
<div className="shimmer h-4 w-2/5 rounded" />
<div className="shimmer h-3 w-3/4 rounded" />
</div>
<div className="shimmer h-3 w-16 rounded" />
</div>
))}
</div>
</div>
{/* devai-tools(niche):浏览器拟物条 + 三列圆角卡片 */}
<div className="loading-variant lv-devai-tools">
<div className="mb-8 overflow-hidden rounded-xl border border-border shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
<div className="shimmer ml-3 h-4 flex-1 rounded-md" />
</div>
<div className="space-y-2 bg-background px-6 py-5">
<div className="shimmer h-6 w-3/4 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border p-4">
<div className="flex items-center gap-3">
<div className="shimmer h-9 w-9 flex-shrink-0 rounded-lg" />
<div className="shimmer h-3.5 w-1/2 flex-1 rounded" />
</div>
<div className="mt-3 space-y-1.5">
<div className="shimmer h-3 w-full rounded" />
<div className="shimmer h-3 w-2/3 rounded" />
</div>
<div className="shimmer mt-3 h-6 w-20 rounded-md" />
</div>
))}
</div>
</div>
{/* findai(minimal):留白标题 + 细线行 */}
<div className="loading-variant lv-findai">
<div className="mb-12 space-y-4">
<div className="shimmer h-10 w-72 max-w-full rounded-lg" />
<div className="shimmer h-4 w-96 max-w-full rounded" />
</div>
<div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-5 border-b border-border py-7">
<div className="shimmer h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex-1 space-y-2.5">
<div className="shimmer h-4 w-1/4 rounded" />
<div className="shimmer h-3 w-1/2 rounded" />
</div>
<div className="shimmer h-3 w-14 rounded" />
</div>
))}
</div>
</div>
</div>
</div>
);
@@ -61,7 +61,7 @@ export default async function RankingsPage({
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<CollectionPageJsonLd
name={`AI Tool Rankings — ${activeBoard.id}`}
description="Community-ranked AI tools"
@@ -33,7 +33,7 @@ export default async function SubmitPage({
const categories = await getCategoriesWithCount().catch(() => []);
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<div className="site-container px-4 py-10 sm:px-6">
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t("title")}
@@ -220,7 +220,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
};
return (
<div className="mx-auto max-w-6xl px-4 pt-8 pb-16 sm:px-6">
<div className="site-container px-4 pt-8 pb-16 sm:px-6">
<SoftwareApplicationJsonLd
name={name}
description={description || ""}
@@ -314,6 +314,25 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</div>
</div>
{/* 网站截图(本地化缓存,/api/screenshots 首次访问时生成) */}
{tool.websiteUrl && (
<section className="space-y-3">
<h2 className="text-xl font-semibold text-foreground">
{t("preview")}
</h2>
<div className="overflow-hidden rounded-xl border border-border bg-secondary">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/screenshots/${tool.slug}`}
alt={`${name} — ${t("preview")}`}
loading="lazy"
decoding="async"
className="aspect-[16/10] w-full object-cover object-top"
/>
</div>
</section>
)}
{/* 封面图 */}
{tool.coverImageUrl && (
<div className="overflow-hidden rounded-xl border border-border">
@@ -98,7 +98,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
return (
<div className="container mx-auto px-4 py-12">
<div className="site-container px-4 py-12">
{/* Structured Data for AI Tools Directory */}
<CollectionPageJsonLd
name={translations.title}
@@ -1,6 +1,13 @@
import type { Metadata } from "next";
import { EmailVerifiedGuard } from "@/features/auth/components/email-verified-guard";
import { NavBar } from "@/features/navigation/components/navbar";
/* 用户/管理界面:禁止索引(robots.txt 只控制抓取,索引控制靠 meta) */
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function ProtectedLayout({
children,
}: {
@@ -1,4 +1,5 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
@@ -15,6 +16,25 @@ export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
/**
* # 站点级 Metadata
* 说明:metadataBase 统一解析相对 OG 图;title.default/template 供未显式
* 写标题的页面兜底(页面级 generatePageMetadata 已用 absolute 全标题)。
*/
export function generateMetadata(): Metadata {
const name = process.env.NEXT_PUBLIC_APP_NAME || 'AI Directory';
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return {
metadataBase: new URL(baseUrl),
title: {
default: `${name} — Find the best AI tools`,
template: `%s · ${name}`,
},
description: 'AI tools directory ranked by real users.',
applicationName: name,
};
}
export default async function LocaleLayout({
children,
params
@@ -4,6 +4,7 @@ import { user, verification, account } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { sendPasswordResetEmail } from "@/lib/email";
import crypto from "crypto";
import { hit as rateHit, clientIp } from "@/lib/rate-limit";
const VERIFICATION_TYPE = "password_reset";
@@ -11,6 +12,15 @@ export async function POST(request: NextRequest) {
try {
const { email } = await request.json();
// 匿名端点限流: 防邮件轰炸/Resend 计费放大(better-auth 限流不覆盖本路由)
if (!rateHit(`fp:ip:${clientIp(request.headers)}`, 10, 900_000) ||
(email && !rateHit(`fp:email:${String(email).toLowerCase()}`, 3, 900_000))) {
return NextResponse.json(
{ message: "If the email exists, a reset link has been sent." },
{ status: 200 }
);
}
if (!email) {
return NextResponse.json(
{ message: "Email is required" },
@@ -14,6 +14,10 @@ import { hashPassword } from "better-auth/crypto";
* 并把 Set-Cookie 头显式透传给浏览器(route handler 里 headers() 不可写,必须走 response)。
*/
export async function POST(req: NextRequest) {
// 生产构建一律禁用演示后门(同时持久化的 password123 演示凭据已在库中清除)
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "MOCK_DISABLED" }, { status: 403 });
}
if (process.env.AUTH_GOOGLE_ID || process.env.AUTH_GITHUB_ID) {
return NextResponse.json({ error: "MOCK_DISABLED" }, { status: 403 });
}
@@ -54,11 +54,11 @@ export async function POST(request: NextRequest) {
.delete(verification)
.where(eq(verification.id, `email-verification:${userId}`));
// Store verification token
// Store verification token — 落库 SHA-256 哈希(与 reset 流一致, 邮件中为明文)
await db.insert(verification).values({
id: `email-verification:${userId}`,
identifier: "email-verification",
value: verificationToken,
value: crypto.createHash("sha256").update(verificationToken).digest("hex"),
expiresAt,
});
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { verification, account } from "@/lib/db/schema";
import { verification, account, session } from "@/lib/db/schema";
import { eq, and, gt, like } from "drizzle-orm";
import crypto from "crypto";
import { hashPassword } from "@/lib/auth";
@@ -92,6 +92,9 @@ export async function POST(request: NextRequest) {
)
.returning({ id: account.id });
// 凭据轮换后吊销该用户全部既有会话(被盗会话即刻失效)
await db.delete(session).where(eq(session.userId, userId));
if (updatedAccounts.length === 0) {
return NextResponse.json(
{ message: "Failed to update password" },
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { user, verification } from "@/lib/db/schema";
import { eq, and, gt } from "drizzle-orm";
import crypto from "crypto";
export async function GET(request: NextRequest) {
try {
@@ -15,19 +16,17 @@ export async function GET(request: NextRequest) {
);
}
// Log for debugging
console.log("Verifying token:", token);
// 凭据不落日志; token 以 SHA-256 哈希比对(与落库侧一致)
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
// First, try to find the verification token
const verificationRecord = await db
.select()
.from(verification)
.where(
eq(verification.value, token)
eq(verification.value, tokenHash)
)
.limit(1);
console.log("Verification record found:", verificationRecord);
// If no record found, check if it's because the user is already verified
if (!verificationRecord || verificationRecord.length === 0) {
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { stat } from "node:fs/promises";
import { db } from "@/lib/db";
import { tool as toolTable } from "@/lib/db/schema";
import {
ensureScreenshot,
isForceAllowed,
isValidSlug,
placeholderSvg,
readLocalScreenshot,
screenshotFilePath,
} from "@/lib/screenshot";
interface RouteContext {
params: Promise<{ slug: string }>;
}
/**
* GET /api/screenshots/[slug] — 工具网站截图(本地化)
* 说明:优先返回本地缓存(ETag/304 + 长缓存);
* 本地没有时调用截图 API 生成并存储后返回;
* 失败时返回占位 SVG(no-store),保证详情页不出现破图。
* 参数:?refresh=1 强制重新截图。
*/
export async function GET(req: NextRequest, ctx: RouteContext) {
const { slug } = await ctx.params;
if (!isValidSlug(slug)) {
return NextResponse.json({ error: "BAD_SLUG" }, { status: 400 });
}
const [row] = await db
.select({ id: toolTable.id, websiteUrl: toolTable.websiteUrl })
.from(toolTable)
.where(eq(toolTable.slug, slug))
.limit(1);
if (!row?.websiteUrl) {
return NextResponse.json({ error: "TOOL_NOT_FOUND" }, { status: 404 });
}
// force 刷新有 60s 冷却(外部 API 调用有成本,防滥用);冷却期内降级为读缓存
const force =
req.nextUrl.searchParams.get("refresh") === "1" && isForceAllowed(slug);
let buf = force ? null : await readLocalScreenshot(slug);
if (!buf) {
try {
buf = await ensureScreenshot(slug, row.websiteUrl, { force });
} catch (err) {
console.error(`[screenshot] ${slug} capture failed:`, err);
}
}
if (!buf) {
return new Response(placeholderSvg(), {
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
// ETag:文件大小 + mtime,命中 If-None-Match 返回 304
let etag = "";
try {
const info = await stat(screenshotFilePath(slug));
etag = `W/"${info.size}-${Math.floor(info.mtimeMs)}"`;
if (etag && req.headers.get("if-none-match") === etag) {
return new Response(null, {
status: 304,
headers: { ETag: etag, "Cache-Control": "public, max-age=3600" },
});
}
} catch {
// 本地文件已被清理时忽略 ETag 逻辑
}
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": "image/webp",
"Content-Length": String(buf.length),
...(etag ? { ETag: etag } : {}),
"Cache-Control": "public, max-age=3600, stale-while-revalidate=604800",
},
});
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { hit as rateHit } from "@/lib/rate-limit";
import { uploadImageFromUrl } from "@/lib/r2-storage";
export async function POST(req: NextRequest) {
@@ -15,6 +16,17 @@ export async function POST(req: NextRequest) {
const userId = session.session.userId;
// 体量预检: 在 formData 全量缓冲之前拒绝超大请求体(默认 12MB)
const declaredLen = Number(req.headers.get("content-length") || 0);
if (declaredLen > 12 * 1024 * 1024) {
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
}
// 每用户上传配额: 50 次/24h(进程内计数, 多实例部署需共享存储)
if (!rateHit(`upload:${userId}`, 50, 24 * 3600 * 1000)) {
return NextResponse.json({ error: "Upload quota exceeded, try later" }, { status: 429 });
}
// Parse form data
const formData = await req.formData();
const file = formData.get('file') as File;
@@ -23,9 +35,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Check file type
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
// Check file type: MIME 白名单(明确排除 image/svg+xml——脚本化 SVG 会以
// 攻击者声明的 Content-Type 在存储 origin 执行)
const ALLOWED_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
if (!ALLOWED_MIME.has(file.type)) {
return NextResponse.json({ error: "File must be png/jpeg/webp/gif" }, { status: 400 });
}
// Check file size (max 10MB)
@@ -40,7 +54,7 @@ export async function POST(req: NextRequest) {
// Generate unique filename
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
const extension = file.type.split('/')[1] || 'png';
const extension = ALLOWED_MIME.has(file.type) ? file.type.split('/')[1].replace('+xml', '') : 'png';
const filename = `uploads/${userId}/${timestamp}_${random}.${extension}`;
// For now, save to a temporary location
+34
View File
@@ -0,0 +1,34 @@
import { MetadataRoute } from 'next'
/**
* # 动态 robots.txt
* 说明:Sitemap 指向本站域名(NEXT_PUBLIC_APP_URL),避免静态文件里
* 硬编码线上域名导致开发/多站环境指向错误。
* 语义区分:robots.txt 管抓取入口;页面 noindex 由
* (admin)/(protected)/(auth) 布局的 robots meta 负责。
*/
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/admin/',
'/*/signup',
'/*/login',
'/*/check-email',
'/*/verify-email',
'/*/forgot-password',
'/*/reset-password',
'/*/dashboard',
'/*/profile',
],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
}
}
+92 -59
View File
@@ -103,6 +103,13 @@ html {
.text-balance {
text-wrap: balance;
}
/* 站点统一容器:宽度由各主题的 --container 令牌决定,
header / footer / 所有营销页面共用,保证整站宽度一致 */
.site-container {
margin-inline: auto;
width: 100%;
max-width: var(--container, 80rem);
}
}
/* 移动端抽屉滑入动画(交互反馈) */
@@ -146,33 +153,17 @@ html {
--ring: 224 70% 62%;
}
[data-theme="ai-stack"] .rounded-xl { border-radius: 0.625rem; }
[data-theme="ai-stack"] .hero-dark {
background: linear-gradient(180deg, hsl(224 55% 12%) 0%, hsl(224 50% 20%) 75%, hsl(var(--background)) 100%);
}
[data-theme="ai-stack"] .hero-dark h1 { color: white; }
[data-theme="ai-stack"] .hero-dark p,
[data-theme="ai-stack"] .hero-dark span { color: rgba(255, 255, 255, 0.7); }
[data-theme="ai-stack"] .hero-dark input {
background: white;
border-color: transparent;
color: hsl(224 40% 15%);
}
[data-theme="ai-stack"] .hero-dark input::placeholder { color: hsl(220 15% 55%); }
[data-theme="ai-stack"] .hero-dark .bg-secondary {
background-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
/* 卡片四边完整 1px 描边;hover 用内嵌 3px 主题色线做强调,不改动边框本身 */
[data-theme="ai-stack"] .group.relative {
border-left: 3px solid transparent;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
[data-theme="ai-stack"] .group.relative:hover {
border-left-color: hsl(var(--primary));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
box-shadow: inset 3px 0 0 hsl(var(--primary)), 0 4px 14px rgba(15, 23, 42, 0.08);
}
/* ---------- 2. LaunchAI:暖橙社区(浅:暖白橙 / 深:深棕橙) ---------- */
[data-theme="launchai"] {
--container: 72rem;
--primary: 16 85% 50%;
--primary-foreground: 0 0% 100%;
--background: 30 55% 97%;
@@ -193,23 +184,10 @@ html {
--ring: 20 90% 56%;
}
[data-theme="launchai"] h1, [data-theme="launchai"] h2 { letter-spacing: -0.02em; }
[data-theme="launchai"] .hero-launch {
background: linear-gradient(135deg, hsl(16 90% 48%) 0%, hsl(26 90% 52%) 100%);
}
[data-theme="launchai"].dark .hero-launch {
background: linear-gradient(135deg, hsl(16 80% 38%) 0%, hsl(26 80% 42%) 100%);
}
[data-theme="launchai"] .hero-launch h1,
[data-theme="launchai"] .hero-launch p { color: white; }
[data-theme="launchai"] .hero-launch .text-muted-foreground { color: rgba(255, 255, 255, 0.85); }
[data-theme="launchai"] .hero-launch a.bg-primary,
[data-theme="launchai"] .hero-launch button.bg-primary {
background: white;
color: hsl(16 90% 46%);
}
/* ---------- 3. The AI Ledger:报纸(浅:纸白墨黑 / 深:炭黑米白) ---------- */
[data-theme="the-ai-ledger"] {
--container: 64rem;
--primary: 24 10% 12%;
--primary-foreground: 40 40% 98%;
--background: 42 45% 97%;
@@ -244,15 +222,11 @@ html {
[data-theme="the-ai-ledger"] .shadow-sm,
[data-theme="the-ai-ledger"] .shadow-lg { box-shadow: none; }
[data-theme="the-ai-ledger"] header { border-bottom: 3px double hsl(var(--border)); }
[data-theme="the-ai-ledger"] .hero-mast p:first-of-type {
text-transform: uppercase;
letter-spacing: 0.35em;
font-size: 11px;
}
[data-theme="the-ai-ledger"] .group.relative:hover h3 { text-decoration: underline; text-underline-offset: 3px; }
/* ---------- 4. DevAI.tools:终端(深:默认深色 / 浅:日间终端) ---------- */
[data-theme="devai-tools"] {
--container: 64rem;
--primary: 150 60% 38%;
--primary-foreground: 0 0% 100%;
--background: 150 20% 97%;
@@ -284,26 +258,17 @@ html {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
letter-spacing: -0.01em;
}
[data-theme="devai-tools"].dark {
background-image: repeating-linear-gradient(
0deg,
hsl(150 60% 45% / 0.015) 0px,
hsl(150 60% 45% / 0.015) 1px,
transparent 1px,
transparent 3px
);
}
[data-theme="devai-tools"] .group.relative {
border: 1px solid hsl(var(--border));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.15s ease;
}
[data-theme="devai-tools"].dark .group.relative:hover {
border-color: hsl(150 60% 45% / 0.5);
box-shadow: 0 0 24px hsl(150 60% 45% / 0.06);
}
/* ---------- 5. findai:极简(浅:纯白紫 / 深:深紫黑) ---------- */
[data-theme="findai"] {
--container: 56rem;
--primary: 262 65% 52%;
--primary-foreground: 0 0% 100%;
--ring: 262 65% 52%;
@@ -326,8 +291,8 @@ html {
}
[data-theme="findai"] .group.relative:hover { background-color: hsl(var(--muted)); }
[data-theme="findai"] input:focus {
box-shadow: 0 0 0 6px hsl(var(--primary) / 0.08), 0 8px 30px hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.4);
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.35);
border-color: hsl(var(--primary) / 0.5);
}
/* ============================================================
@@ -395,15 +360,19 @@ html {
导航体验:顶部进度条 + 页面切换过渡 + View Transitions
============================================================ */
/* 顶部进度条:主题色渐变 + 微光晕 */
/* 窄容器站点: 头部搜索会挤占导航(findai 896px / ledger 1024px), 隐藏之;
两站均有独立的大搜索入口, 头部搜索冗余 */
[data-theme="findai"] .header-search,
[data-theme="the-ai-ledger"] .header-search { display: none; }
/* 窄视口(1024-1120): 头部搜索挤压导航, 隐藏之(各站页内均有独立搜索入口) */
@media (max-width: 1120px) {
form.header-search { display: none !important; }
}
/* 顶部进度条:主题色,不加发光 */
.nav-progress-fill {
background: linear-gradient(
90deg,
hsl(var(--primary) / 0.4),
hsl(var(--primary)) 60%,
hsl(var(--primary) / 0.9)
);
box-shadow: 0 0 10px hsl(var(--primary) / 0.55);
background: hsl(var(--primary));
transition: width 0.22s ease-out, opacity 0.28s ease-in;
}
@@ -444,3 +413,67 @@ html {
from { opacity: 0; }
to { opacity: 1; }
}
/* ---------- 骨架屏:微光 + 淡入淡出(列表过滤与路由加载共用) ---------- */
/* 微光色块:底色 secondary,一道高光周期性扫过(token 取色,深浅主题均适用) */
.shimmer {
position: relative;
overflow: hidden;
background: hsl(var(--secondary));
}
.shimmer::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
hsl(var(--background) / 0.6),
transparent
);
animation: shimmer-sweep 1.6s ease-in-out infinite;
}
@keyframes shimmer-sweep {
100% { transform: translateX(100%); }
}
/* 骨架覆盖层淡入:挂载时播放一次,之后的淡出由 opacity 过渡接管 */
.skeleton-in {
animation: skeleton-fade-in 0.25s ease-out;
}
@keyframes skeleton-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* 新内容卡片入场:淡入 + 轻微上移,按序号 inline delay 错峰 */
.card-enter {
animation: card-enter 0.36s ease-out both;
}
@keyframes card-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* 路由级骨架屏:延迟淡入 —— 快速导航来不及显示,慢导航平滑浮现 */
.loading-shell {
animation: skeleton-fade-in 0.3s ease-out 0.15s both;
}
/* 路由级骨架屏按站点取形态:<html data-theme> 决定显示哪一套(新站点需补一条规则) */
.loading-variant { display: none; }
[data-theme="ai-stack"] .lv-ai-stack { display: block; }
[data-theme="launchai"] .lv-launchai { display: block; }
[data-theme="the-ai-ledger"] .lv-the-ai-ledger { display: block; }
[data-theme="devai-tools"] .lv-devai-tools { display: block; }
[data-theme="findai"] .lv-findai { display: block; }
@media (prefers-reduced-motion: reduce) {
.shimmer::after,
.skeleton-in,
.card-enter,
.loading-shell {
animation: none;
}
}
+11
View File
@@ -0,0 +1,11 @@
// MDX 模块通配声明:让 TS(含 TS7 native)识别 content/blog 下的 .mdx 动态导入
declare module "*.mdx" {
import type { ComponentType } from "react";
const MDXContent: ComponentType<Record<string, unknown>>;
export default MDXContent;
}
declare module "*.md" {
import type { ComponentType } from "react";
const MDContent: ComponentType<Record<string, unknown>>;
export default MDContent;
}