feat: 初始化 Toolso.AI 开源 AI 工具导航站项目

🎯 项目概述
- 一个开源的 AI 工具导航站,帮助用户发现和探索优秀的 AI 工具
- 基于 Next.js 16 + React 19 构建的现代化全栈应用

✨ 核心功能
- AI 工具目录:分类展示、搜索筛选、工具详情页
- 工具提交:用户可提交新工具,社区驱动
- 管理后台:工具审核、用户管理、角色权限控制
- 博客系统:MDX 驱动,AI 资讯和教程
- 国际化支持:中文/英文双语 (next-intl)
- 用户认证:Better Auth + Google OAuth
- 深色/浅色主题切换

🛠 技术栈
- 前端:Next.js 16 (App Router) + React 19 + TypeScript
- 样式:Tailwind CSS + Framer Motion 动画
- UI 组件:自定义组件库 + Radix UI
- 表单验证:React Hook Form + Zod
- 数据库:PostgreSQL + Drizzle ORM
- 认证:Better Auth (邮箱密码 + Google OAuth)
- 邮件:Resend (验证邮件 + Newsletter)
- 存储:Cloudflare R2 / AWS S3 兼容

📁 项目结构
- src/app/[locale]/(auth) - 认证页面
- src/app/[locale]/(marketing) - 营销页面 (首页、博客、工具目录)
- src/app/[locale]/(protected) - 受保护页面 (仪表板、个人资料)
- src/app/[locale]/(admin) - 管理后台
- src/features - 功能模块
- src/lib - 核心业务逻辑
- messages - 国际化翻译文件

🗄 数据库
- 包含 user, session, account, verification, passwordResetToken 等核心表
- 支持 Drizzle ORM 迁移管理

📝 文档
- 完整的 CLAUDE.md 项目指南
- 环境变量配置示例 (.env.example)
- MIT 开源许可证
This commit is contained in:
CoolChange.AI
2026-01-03 23:52:28 +08:00
commit 8976caaaad
191 changed files with 37184 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import React from "react";
export const LogoIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
>
{/* 圆形背景 */}
<circle cx="12" cy="12" r="10" fill="currentColor" />
{/* 字母 T - 代表 Toolso */}
<path
d="M8 8h8M12 8v8"
stroke="hsl(var(--background))"
strokeWidth="2.5"
strokeLinecap="round"
/>
{/* AI 小星芒装饰 */}
<circle cx="17" cy="7" r="1.5" fill="hsl(var(--background))" />
</svg>
);
};
export const Logo = () => {
return (
<Link
href="/"
className="font-normal flex space-x-2 items-center text-sm mr-4 text-foreground px-2 py-1 relative z-20"
>
<LogoIcon />
<span className="font-medium text-foreground">Toolso.AI</span>
</Link>
);
};
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import { useTheme } from "next-themes";
import React, { useId } from "react";
export const Background = () => {
return (
<div className="absolute inset-0 h-full w-full overflow-hidden pointer-events-none z-0">
<div className="absolute inset-0 h-full w-full bg-background pointer-events-none [mask-image:radial-gradient(ellipse_at_center,transparent,white)]" />
{Array.from({ length: 6 }).map((_, index) => (
<div className="flex" key={"grid-column" + index}>
{Array.from({ length: 10 }).map((_, index) => (
<GridBlock key={`grid-row` + index} />
))}
</div>
))}
</div>
);
};
const GridBlock = () => {
return (
<div className="flex flex-col items-start justify-center w-60">
<div className="flex items-center justify-center">
<Dot />
<SVG />
{/* <Dot /> */}
</div>
<SVGVertical className="ml-3" />
</div>
);
};
const Dot = () => {
return (
<div className="h-6 w-6 bg-background flex items-center justify-center rounded-full">
<div className="h-2 w-2 bg-muted rounded-full" />
</div>
);
};
const SVGVertical = ({ className }: { className?: string }) => {
const width = 1;
const height = 140;
const id = useId();
return (
<motion.svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn("text-border", className)}
>
<path d="M0.5 0.5V479" stroke="currentColor" strokeWidth={2} />
<motion.path
d="M0.5 0.5V479"
stroke={`url(#gradient-${id})`}
strokeWidth={2}
/>
<defs>
<motion.linearGradient
id={`gradient-${id}`}
initial={{ x1: 2, y1: -200, x2: 2, y2: -100 }}
animate={{ x1: 2, y1: 400, x2: 2, y2: 600 }}
transition={{
repeat: Infinity,
duration: Math.random() * 2 + 1,
delay: Math.floor(Math.random() * 6) + 5,
}}
gradientUnits="userSpaceOnUse"
>
<motion.stop offset="0%" stopColor="transparent" />
<motion.stop offset="50%" stopColor="var(--neutral-200)" />
<motion.stop offset="100%" stopColor="transparent" />
</motion.linearGradient>
</defs>
</motion.svg>
);
};
const SVG = ({ className }: { className?: string }) => {
const width = 300;
const height = 1;
const id = useId();
return (
<motion.svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn("text-border", className)}
>
<path d="M0.5 0.5H479" stroke="currentColor" />
<motion.path
d="M0.5 0.5H479"
stroke={`url(#gradient-${id})`}
strokeWidth={1}
/>
<defs>
<motion.linearGradient
id={`gradient-${id}`}
initial={{ x1: -200, y1: 0, x2: -100, y2: 0 }}
animate={{ x1: 400, y1: 0, x2: 600, y2: 0 }}
transition={{
repeat: Infinity,
duration: Math.random() * 2 + 1,
delay: Math.floor(Math.random() * 6) + 5,
}}
gradientUnits="userSpaceOnUse"
>
<motion.stop offset="0%" stopColor="transparent" />
<motion.stop offset="50%" stopColor="var(--neutral-200)" />
<motion.stop offset="100%" stopColor="transparent" />
</motion.linearGradient>
</defs>
</motion.svg>
);
};
// Use the below rect to debug linear gradient
{
/* <motion.rect width={width} height={width} fill={`url(#gradient-${id})`} /> */
}
+35
View File
@@ -0,0 +1,35 @@
import React from "react";
export const Badge: React.FC<
{ children: React.ReactNode } & React.ComponentPropsWithoutRef<"button">
> = ({ children, ...props }) => {
return (
<button
{...props}
className="bg-secondary no-underline group cursor-pointer relative md:shadow-2xl rounded-full p-px text-[10px] sm:text-xs font-semibold leading-6 text-muted-foreground inline-block w-fit mx-auto"
>
<span className="absolute inset-0 overflow-hidden rounded-full">
<span className="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
</span>
<div className="relative flex space-x-2 items-center z-10 rounded-full bg-muted py-1.5 px-4 ring-1 ring-border">
<span>{children}</span>
<svg
fill="none"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.75 8.75L14.25 12L10.75 15.25"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
</div>
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-muted-foreground/0 via-muted-foreground/90 to-muted-foreground/0 transition-opacity duration-500 group-hover:opacity-40" />
</button>
);
};
+52
View File
@@ -0,0 +1,52 @@
import { Link } from "next-view-transitions";
import React from "react";
import { BlurImage } from "./blur-image";
import { LogoIcon } from "./Logo";
import Image from "next/image";
import Balancer from "react-wrap-balancer";
import { BlogWithSlug } from "@/lib/blog";
export const BlogCard = ({ blog }: { blog: BlogWithSlug }) => {
const truncate = (text: string, length: number) => {
return text.length > length ? text.slice(0, length) + "..." : text;
};
return (
<Link
className="shadow-derek rounded-3xl border border-border w-full bg-card text-card-foreground overflow-hidden hover:scale-[1.02] transition duration-200"
href={`/blog/${blog.slug}`}
>
{blog.image ? (
<BlurImage
src={blog.image || ""}
alt={blog.title}
height="800"
width="800"
className="h-52 object-cover object-top w-full"
/>
) : (
<div className="h-52 flex items-center justify-center bg-card">
<LogoIcon />
</div>
)}
<div className="p-4 md:p-8 bg-card">
<div className="flex space-x-2 items-center mb-2">
<Image
src={blog.author.src}
alt={blog.author.name}
width={20}
height={20}
className="rounded-full h-5 w-5"
unoptimized={blog.author.src.startsWith('http')}
/>
<p className="text-sm font-normal text-muted-foreground">{blog.author.name}</p>
</div>
<p className="text-lg font-bold mb-4 text-foreground">
<Balancer>{blog.title}</Balancer>
</p>
<p className="text-left text-sm mt-2 text-muted-foreground">
{truncate(blog.description, 100)}
</p>
</div>
</Link>
);
};
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { BlogWithSlug } from "@/lib/blog";
import { IconArrowLeft } from "@tabler/icons-react";
import { Container } from "./container";
import Image from "next/image";
import { Logo } from "./Logo";
import Link from "next/link";
import { format } from "date-fns";
export function BlogLayout({
blog,
children,
}: {
blog: BlogWithSlug;
children: React.ReactNode;
}) {
return (
<Container className="mt-16 lg:mt-32">
<div className="flex justify-between items-center px-2 py-8">
<Link href="/blog" className="flex space-x-2 items-center">
<IconArrowLeft className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Back</span>
</Link>
<div className="flex space-x-2 items-center">
<div className="flex space-x-2 items-center ">
<Image
src={blog.author.src}
alt={blog.author.name}
width={20}
height={20}
className="rounded-full h-5 w-5"
/>
<p className="text-sm font-normal text-muted-foreground">
{blog.author.name}
</p>
</div>
<div className="h-5 rounded-lg w-0.5 bg-border" />
<time dateTime={blog.date} className="flex items-center text-base ">
<span className="text-muted-foreground text-sm">
{format(new Date(blog.date), "MMMM dd, yyyy")}
</span>
</time>
</div>
</div>
<div className="max-w-4xl mx-auto">
{blog.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={blog.image}
className="h-40 md:h-96 w-full aspect-square object-cover rounded-3xl"
alt={blog.title}
/>
) : (
<div className="h-40 md:h-96 w-full aspect-square rounded-3xl shadow-derek bg-card flex items-center justify-center">
<Logo />
</div>
)}
</div>
<div className="xl:relative">
<div className="mx-auto max-w-2xl">
<article className="pb-8">
<header className="flex flex-col">
<h1 className="mt-8 text-4xl font-bold tracking-tight text-foreground sm:text-5xl ">
{blog.title}
</h1>
</header>
<div
className="mt-8 prose prose-sm dark:prose-invert"
data-mdx-content
>
{children}
</div>
</article>
</div>
</div>
</Container>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import clsx from "clsx";
import Image from "next/image";
import React, { useState } from "react";
interface IBlurImage {
height?: any;
width?: any;
src?: string | any;
objectFit?: any;
className?: string | any;
alt?: string | undefined;
[x: string]: any;
}
export const BlurImage = ({
height,
width,
src,
className,
objectFit,
alt,
...rest
}: IBlurImage) => {
const [isLoading, setLoading] = useState(true);
// 判断是否为远程图片
const isRemoteImage = typeof src === 'string' && src.startsWith('http');
return (
<Image
className={clsx(
"transition duration-300 transform",
isLoading ? "blur-sm scale-105" : "blur-0 scale-100",
className
)}
onLoad={() => setLoading(false)}
src={src}
width={width}
height={height}
loading="lazy"
decoding="async"
blurDataURL={isRemoteImage ? undefined : src}
alt={alt ? alt : "Avatar"}
unoptimized={isRemoteImage}
{...rest}
/>
);
};
+47
View File
@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
import React from "react";
export const Button: React.FC<{
children?: React.ReactNode;
className?: string;
variant?: "simple" | "outline" | "primary";
size?: "sm" | "md" | "lg";
as?: React.ElementType;
[x: string]: any;
}> = ({
children,
className,
variant = "primary",
size = "md",
as: Tag = "button",
...props
}) => {
const sizeClass =
size === "sm"
? "text-xs px-3 py-1.5"
: size === "lg"
? "text-base px-6 py-3"
: "text-sm md:text-sm px-4 py-2";
const variantClass =
variant === "simple"
? "bg-transparent hover:bg-hover border border-transparent text-foreground hover:text-hover-foreground transition font-medium duration-200 rounded-full flex items-center justify-center"
: variant === "outline"
? "bg-background hover:bg-primary hover:shadow-xl text-foreground border border-border hover:text-primary-foreground transition font-medium duration-200 rounded-full flex items-center justify-center"
: variant === "primary"
? "bg-primary hover:bg-primary/90 border border-transparent text-primary-foreground transition font-medium duration-200 rounded-full flex items-center justify-center shadow-[0px_-1px_0px_0px_rgba(255,255,255,0.25)_inset,_0px_1px_0px_0px_rgba(255,255,255,0.25)_inset]"
: "";
return (
<Tag
className={cn(
"relative z-10",
variantClass,
sizeClass,
className
)}
{...props}
>
{children ?? `Get Started`}
</Tag>
);
};
+159
View File
@@ -0,0 +1,159 @@
.gridLineHorizontal {
--background: #ffffff;
--color: rgba(0, 0, 0, 0.2);
--height: 1px;
--width: 5px;
--fade-stop: 90%;
/* Bleed in or out from the container */
--offset: -100px;
position: absolute;
width: calc(100% + var(--offset));
height: var(--height);
left: calc(var(--offset) / 2 * -1);
background: linear-gradient(
to right,
var(--color),
var(--color) 50%,
transparent 0,
transparent
);
background-size: var(--width) var(--height);
/* Fade out the edges */
mask: linear-gradient(
to left,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to right, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
-webkit-mask: linear-gradient(
to left,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to right, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
mask-composite: exclude;
-webkit-mask-composite: exclude;
z-index: 20;
}
.gridLineHorizontalDark {
--background: #ffffff;
--color: rgba(255, 255, 255, 0.2);
--height: 1px;
--width: 5px;
--fade-stop: 90%;
/* Bleed in or out from the container */
--offset: -100px;
position: absolute;
width: calc(100% + var(--offset));
height: var(--height);
left: calc(var(--offset) / 2 * -1);
background: linear-gradient(
to right,
var(--color),
var(--color) 50%,
transparent 0,
transparent
);
background-size: var(--width) var(--height);
/* Fade out the edges */
mask: linear-gradient(
to left,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to right, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
-webkit-mask: linear-gradient(
to left,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to right, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
mask-composite: exclude;
-webkit-mask-composite: exclude;
z-index: 20;
}
.gridLineVertical {
--background: #ffffff;
--color: rgba(0, 0, 0, 0.2);
--height: 5px;
--width: 1px;
--fade-stop: 90%;
--offset: -100px;
position: absolute;
height: calc(100% + var(--offset));
width: var(--width);
top: calc(var(--offset) / 2 * -1);
background: linear-gradient(
to bottom,
var(--color),
var(--color) 50%,
transparent 0,
transparent
);
background-size: var(--width) var(--height);
mask: linear-gradient(to top, var(--background) var(--fade-stop), transparent),
linear-gradient(to bottom, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
-webkit-mask: linear-gradient(
to top,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to bottom, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
mask-composite: exclude;
-webkit-mask-composite: exclude;
z-index: 20;
}
.gridLineVerticalDark {
--background: #ffffff;
--color: rgba(255, 255, 255, 0.2);
--height: 5px;
--width: 1px;
--fade-stop: 90%;
--offset: -100px;
position: absolute;
height: calc(100% + var(--offset));
width: var(--width);
top: calc(var(--offset) / 2 * -1);
background: linear-gradient(
to bottom,
var(--color),
var(--color) 50%,
transparent 0,
transparent
);
background-size: var(--width) var(--height);
mask: linear-gradient(to top, var(--background) var(--fade-stop), transparent),
linear-gradient(to bottom, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
-webkit-mask: linear-gradient(
to top,
var(--background) var(--fade-stop),
transparent
),
linear-gradient(to bottom, var(--background) var(--fade-stop), transparent),
linear-gradient(black, black);
mask-composite: exclude;
-webkit-mask-composite: exclude;
z-index: 20;
}
+14
View File
@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
import React from "react";
export const Container = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div className={cn(`max-w-7xl mx-auto px-4 `, className)}>{children}</div>
);
};
+45
View File
@@ -0,0 +1,45 @@
"use client";
import React from "react";
import { Button } from "./button";
import { useTranslations } from 'next-intl';
import { LocaleLink } from "@/components/locale-link";
export const CTA = () => {
const t = useTranslations('cta');
return (
<section className="py-60 w-full overflow-hidden relative z-30">
<div className="bg-background">
<div className="mx-auto w-full relative z-20 sm:max-w-[40rem] md:max-w-[48rem] lg:max-w-[64rem] xl:max-w-[80rem] bg-gradient-to-br from-slate-800 to-gray-900 dark:from-neutral-900 dark:to-neutral-950 sm:rounded-2xl">
<div className="relative -mx-6 sm:mx-0 sm:rounded-2xl overflow-hidden px-6 md:px-8 ">
<div
className="absolute inset-0 w-full h-full opacity-10 bg-noise fade-vignette [mask-image:radial-gradient(#fff,transparent,75%)]"
style={{
backgroundImage: "url(/noise.webp)",
backgroundSize: "30%",
}}
></div>
<div
className="pointer-events-none absolute inset-y-0 right-0 select-none overflow-hidden rounded-2xl"
style={{
mask: "radial-gradient(33.875rem 33.875rem at calc(100% - 8.9375rem) 0, white 3%, transparent 70%)",
}}
></div>
<div className="relative px-6 pb-14 pt-20 sm:px-10 sm:pb-20 lg:px-[4.5rem]">
<h2 className="text-center text-balance mx-auto text-3xl md:text-5xl font-semibold tracking-[-0.015em] text-white">
{t('title')}
</h2>
<p className="mt-4 max-w-[26rem] text-center mx-auto text-base/6 text-white/80">
{t('description')}
</p>
<div className="relative z-10 mx-auto flex justify-center mt-6">
<Button as={LocaleLink} href="/tools">{t('button')}</Button>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
+30
View File
@@ -0,0 +1,30 @@
"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>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import Link from "next/link";
import { useLocale } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading";
import { Subheading } from "./subheading";
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 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">
{locale === "zh" ? "精选工具" : "Featured Tools"}
</Heading>
<Subheading className="text-center">
{locale === "zh"
? "精选优质 AI 工具,助力提升工作效率"
: "Curated collection of AI tools to boost your productivity"}
</Subheading>
{/* 分类导航 */}
{categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => (
<Link
key={category.id}
href={`/${locale}/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>
</Link>
))}
</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) => (
<Link
key={tool.id}
href={`/${locale}/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">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
alt={getName(tool)}
className="h-12 w-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-secondary flex items-center justify-center flex-shrink-0">
<span className="text-xl font-bold text-muted-foreground">
{getName(tool).charAt(0).toUpperCase()}
</span>
</div>
)}
<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>{locale === "zh" ? "访问网站" : "Visit website"}</span>
</div>
)}
</Link>
))}
</div>
{/* 空状态 */}
{tools.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
{locale === "zh" ? "暂无工具,请先在后台添加" : "No tools yet. Add some in the admin panel."}
</div>
)}
{/* 查看更多按钮 */}
{tools.length > 0 && (
<div className="flex justify-center mt-12">
<Link
href={`/${locale}/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"
>
{locale === "zh" ? "查看全部工具" : "View All Tools"}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"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>
);
};
+101
View File
@@ -0,0 +1,101 @@
"use client";
import React from "react";
import { Logo } from "./Logo";
import { useTranslations } from 'next-intl';
import { LocaleLink } from './locale-link';
export const Footer = () => {
const t = useTranslations();
const links = [
{
name: t('navigation.main.blog'),
href: "/blog",
},
{
name: t('navigation.main.contact'),
href: "/contact",
},
];
const legal = [
{
name: t('navigation.footer.legal.terms'),
href: "/terms",
},
{
name: t('navigation.footer.legal.privacy'),
href: "/privacy",
},
{
name: t('navigation.footer.legal.cookies'),
href: "/cookies",
},
];
const socials = [
{
name: t('footer.social.twitter'),
href: "https://x.com/ToolsoAI",
external: true,
},
{
name: t('footer.social.github'),
href: "https://github.com/coolchange-ai/toolso-ai-open",
external: true,
},
];
return (
<div className="relative">
<div className="border-t border-border px-8 pt-20 pb-32 relative bg-background">
<div className="max-w-7xl mx-auto">
{/* Footer Links Section */}
<div className="text-sm text-muted-foreground flex sm:flex-row flex-col justify-between items-start">
<div>
<div className="mr-4 md:flex mb-4">
<Logo />
</div>
<div>{t('common.brand.copyright')}</div>
<div className="mt-2">{t('common.brand.allRightsReserved')}</div>
</div>
<div className="grid grid-cols-3 gap-10 items-start mt-10 md:mt-0">
<div className="flex justify-center space-y-4 flex-col mt-4">
{links.map((link) => (
<LocaleLink
key={link.name}
className="transition-colors hover:text-foreground text-muted-foreground text-xs sm:text-sm"
href={link.href}
>
{link.name}
</LocaleLink>
))}
</div>
<div className="flex justify-center space-y-4 flex-col mt-4">
{legal.map((link) => (
<LocaleLink
key={link.name}
className="transition-colors hover:text-foreground text-muted-foreground text-xs sm:text-sm"
href={link.href}
>
{link.name}
</LocaleLink>
))}
</div>
<div className="flex justify-center space-y-4 flex-col mt-4">
{socials.map((link) => (
<a
key={link.name}
className="transition-colors hover:text-foreground text-muted-foreground text-xs sm:text-sm"
href={link.href}
target="_blank"
rel="noopener noreferrer"
>
{link.name}
</a>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
"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}
/>
);
};
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import styles from "./common.module.css";
export const GridLineHorizontal = ({ ...props }) => {
return (
<>
<div
className={cn(styles.gridLineHorizontalDark, "hidden dark:block ")}
{...props}
/>
<div
className={cn(styles.gridLineHorizontal, "block dark:hidden ")}
{...props}
/>
</>
);
};
export const GridLineVertical = ({ ...props }) => {
return (
<>
<div
className={cn(styles.gridLineVerticalDark, "hidden dark:block ")}
{...props}
/>
<div
className={cn(styles.gridLineVertical, "block dark:hidden ")}
{...props}
/>
</>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { AnimationProps, MotionProps } 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 | AnimationProps>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | AnimationProps>) => {
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>
);
};
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { Button } from "./button";
import { HiArrowRight } from "react-icons/hi2";
import { motion } from "framer-motion";
import { LocaleLink } from "@/components/locale-link";
import { useTranslations } from 'next-intl';
export const Hero = () => {
const t = useTranslations('hero');
return (
<div className="flex flex-col relative overflow-hidden">
<motion.h1
initial={{
y: 40,
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.5,
}}
className="text-4xl md:text-6xl lg:text-[5rem] font-semibold max-w-6xl mx-auto text-center mt-6 relative z-10"
>
{t('title')}
</motion.h1>
<motion.h2
initial={{
y: 40,
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.5,
delay: 0.2,
}}
className="text-center mt-6 text-base md:text-xl text-muted-foreground max-w-3xl mx-auto relative z-10 font-normal"
>
{t('description')}
</motion.h2>
<motion.div
initial={{
y: 80,
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.5,
delay: 0.4,
}}
className="flex items-center gap-4 justify-center mt-6 relative z-10"
>
<Button
as={LocaleLink}
href="/tools"
>
{t('cta.primary')}
</Button>
<Button
variant="simple"
as={LocaleLink}
href="/blog"
className="flex space-x-2 items-center group"
>
<span>{t('cta.secondary')}</span>
<HiArrowRight className="text-muted-foreground group-hover:translate-x-1 stroke-[1px] h-3 w-3 transition-transform duration-200" />
</Button>
</motion.div>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
"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>
);
};
+18
View File
@@ -0,0 +1,18 @@
"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>
);
}
+153
View File
@@ -0,0 +1,153 @@
import Script from 'next/script'
interface WebSiteJsonLdProps {
name: string
description: string
url: string
locale: string
}
/**
* WebSite structured data for the homepage
* Helps search engines understand the site is an AI tools directory
*/
export function WebSiteJsonLd({ name, description, url, locale }: WebSiteJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name,
description,
url,
inLanguage: locale === 'zh' ? 'zh-CN' : 'en-US',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${url}/tools?search={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
}
return (
<Script
id="website-jsonld"
type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
interface OrganizationJsonLdProps {
name: string
url: string
logo: string
sameAs?: string[]
}
/**
* Organization structured data
*/
export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: OrganizationJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name,
url,
logo,
sameAs,
}
return (
<Script
id="organization-jsonld"
type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
interface SoftwareApplicationJsonLdProps {
name: string
description: string
url: string
image?: string
category?: string
applicationCategory?: string
}
/**
* SoftwareApplication structured data for individual AI tools
* Use this on tool detail pages
*/
export function SoftwareApplicationJsonLd({
name,
description,
url,
image,
category,
applicationCategory = 'AI Tool',
}: SoftwareApplicationJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name,
description,
url,
...(image && { image }),
...(category && { category }),
applicationCategory,
operatingSystem: 'Web',
}
return (
<Script
id="software-jsonld"
type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
interface CollectionPageJsonLdProps {
name: string
description: string
url: string
itemCount?: number
}
/**
* CollectionPage structured data for the tools listing page
*/
export function CollectionPageJsonLd({
name,
description,
url,
itemCount,
}: CollectionPageJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name,
description,
url,
...(itemCount && {
mainEntity: {
'@type': 'ItemList',
numberOfItems: itemCount,
},
}),
}
return (
<Script
id="collection-jsonld"
type="application/ld+json"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { locales, localeNames } from '@/i18n.config';
import type { Locale } from '@/i18n.config';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Globe, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const switchLocale = (newLocale: Locale) => {
if (newLocale === locale) {
setIsOpen(false);
return;
}
let path = pathname;
for (const loc of locales) {
if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
path = pathname.slice(loc.length + 1) || '/';
break;
}
}
const normalizedPath = path === '/' ? '' : path;
router.push(`/${newLocale}${normalizedPath}`);
setIsOpen(false);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Close on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
};
}
}, [isOpen]);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-full transition-all duration-200",
"border border-border",
"bg-popover text-popover-foreground",
"hover:bg-hover",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
)}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="Select language"
>
<Globe className="w-4 h-4" />
<span className="font-medium">{localeNames[locale as Locale]}</span>
<ChevronDown
className={cn(
"w-3 h-3 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div
className={cn(
"absolute right-0 mt-2 py-1 min-w-[150px]",
"bg-popover text-popover-foreground",
"border border-border",
"rounded-lg shadow-lg",
"animate-in fade-in-0 zoom-in-95",
"z-50"
)}
role="menu"
aria-orientation="vertical"
>
{locales.map((loc) => {
const isActive = loc === locale;
return (
<button
key={loc}
onClick={() => switchLocale(loc)}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm",
"hover:bg-hover",
"transition-colors duration-150",
isActive && "bg-accent text-accent-foreground"
)}
role="menuitem"
>
<span className={cn(
"font-medium",
isActive ? "text-accent-foreground" : "text-muted-foreground"
)}>
{localeNames[loc]}
</span>
{isActive && (
<Check className="w-4 h-4 text-primary" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useLocale } from 'next-intl';
import { Link } from 'next-view-transitions';
interface LocaleLinkProps {
href: string;
children: React.ReactNode;
className?: string;
[key: string]: any;
}
export function LocaleLink({ href, children, ...props }: LocaleLinkProps) {
const locale = useLocale();
if (href.startsWith('http') || href.startsWith('//')) {
return <Link href={href} {...props}>{children}</Link>;
}
const normalizedHref = href.startsWith('/') ? href : `/${href}`;
if (
normalizedHref === `/${locale}` ||
normalizedHref.startsWith(`/${locale}/`)
) {
return <Link href={normalizedHref} {...props}>{children}</Link>;
}
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
return <Link href={localizedHref} {...props}>{children}</Link>;
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ReactNode } from "react";
interface MarkdownMessageProps {
content: string;
isStreaming?: boolean;
}
export function MarkdownMessage({ content, isStreaming }: MarkdownMessageProps) {
return (
<div className="markdown-content prose prose-neutral dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义渲染组件
h1: ({ children }) => (
<h1 className="text-2xl font-bold mt-4 mb-2 text-foreground">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mt-3 mb-2 text-foreground">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-medium mt-2 mb-1 text-foreground">{children}</h3>
),
p: ({ children }) => (
<p className="mb-2 text-foreground leading-relaxed">{children}</p>
),
ul: ({ children }) => (
<ul className="list-disc list-inside mb-2 space-y-1 text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-2 space-y-1 text-foreground">
{children}
</ol>
),
li: ({ children }) => (
<li className="ml-2 text-foreground">{children}</li>
),
code: ({ inline, children }: { inline?: boolean; children?: ReactNode }) => {
if (inline) {
return (
<code className="px-1.5 py-0.5 rounded bg-muted text-sm font-mono text-muted-foreground">
{children}
</code>
);
}
return (
<code className="block p-3 rounded-lg bg-muted text-sm font-mono overflow-x-auto">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-2 overflow-x-auto">{children}</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-border pl-4 my-2 italic text-muted-foreground">
{children}
</blockquote>
),
strong: ({ children }) => (
<strong className="font-bold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic">{children}</em>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground underline hover:text-foreground"
>
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full divide-y divide-border">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => (
<tbody className="bg-card divide-y divide-border">
{children}
</tbody>
),
tr: ({ children }) => <tr>{children}</tr>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 whitespace-nowrap text-sm text-foreground">
{children}
</td>
),
hr: () => (
<hr className="my-4 border-border" />
),
}}
>
{content}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-1 h-4 ml-1 bg-muted-foreground animate-pulse" />
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import { useTheme } from "next-themes";
import { MoonIcon } from "lucide-react";
import { IconSunLow } from "@tabler/icons-react";
import { motion } from "framer-motion";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const [isClient, setIsClient] = React.useState(false);
React.useEffect(() => {
setIsClient(true);
}, []);
return (
isClient && (
<button
onClick={() => {
theme === "dark" ? setTheme("light") : setTheme("dark");
}}
className="w-10 h-10 flex hover:bg-hover rounded-lg items-center justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
>
{theme === "light" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<IconSunLow className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
{theme === "dark" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.3,
}}
>
<MoonIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</motion.div>
)}
<span className="sr-only">Toggle theme</span>
</button>
)
);
}
+43
View File
@@ -0,0 +1,43 @@
import { EyeIcon, EyeOffIcon } from "lucide-react";
import React, { forwardRef, useState } from "react";
import { cn } from "@/lib/utils";
interface PasswordProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Password = forwardRef<HTMLInputElement, PasswordProps>(
({ className, type, ...props }, ref) => {
const [show, setShow] = useState(false);
return (
<div className="relative">
<input
{...props}
type={show ? "text" : type ?? "password"}
ref={ref}
className={cn(
"block w-full rounded-md border-0 bg-input px-4 pr-10 py-1.5 text-foreground shadow-aceternity placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring",
className
)}
/>
<div className="absolute right-3 top-[30%]">
{!show && (
<EyeIcon
onClick={() => setShow(true)}
className="text-muted-foreground cursor-pointer h-4"
/>
)}
{show && (
<EyeOffIcon
onClick={() => setShow(false)}
className="text-muted-foreground cursor-pointer h-4"
/>
)}
</div>
</div>
);
});
Password.displayName = "Password";
export default Password;
+113
View File
@@ -0,0 +1,113 @@
"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>
);
};
+274
View File
@@ -0,0 +1,274 @@
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>
);
};
+71
View File
@@ -0,0 +1,71 @@
"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>
);
};
+59
View File
@@ -0,0 +1,59 @@
"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>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
import { AnimationProps, MotionProps } 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 | AnimationProps>;
} & MotionProps &
React.HTMLAttributes<HTMLHeadingElement | AnimationProps>) => {
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>
);
};
+48
View File
@@ -0,0 +1,48 @@
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useId } from "react";
export const Switch = ({
checked,
setChecked,
}: {
checked: boolean;
setChecked: (checked: boolean) => void;
}) => {
const id = useId();
return (
<form className="flex space-x-4 antialiased items-center">
<label
htmlFor={id}
className={cn(
"h-4 px-1 w-[40px] flex items-center border border-transparent shadow-[inset_0px_0px_12px_rgba(0,0,0,0.25)] rounded-full relative cursor-pointer transition duration-200",
checked ? "bg-primary" : "bg-muted border-border"
)}
>
<motion.div
initial={{
width: "10px",
x: checked ? -2 : 20,
}}
animate={{
height: ["12px", "10px", "12px"],
width: ["12px", "18px", "12px", "12px"],
x: checked ? 20 : -2,
}}
transition={{
duration: 0.3,
delay: 0.1,
}}
key={String(checked)}
className={cn("h-[20px] block rounded-full bg-background shadow-md z-10")}
></motion.div>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="hidden"
id={id}
/>
</label>
</form>
);
};
@@ -0,0 +1,43 @@
"use client";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
export function TestimonialColumnContainer({
className,
shift = 0,
children,
}: {
className?: string;
shift?: number;
children: React.ReactNode;
}) {
let columnRef = useRef<React.ElementRef<"div">>(null);
let [columnHeight, setColumnHeight] = useState(0);
let duration = `${columnHeight * shift}ms`;
useEffect(() => {
if (!columnRef.current) {
return;
}
let resizeObserver = new window.ResizeObserver(() => {
setColumnHeight(columnRef.current?.offsetHeight ?? 0);
});
resizeObserver.observe(columnRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div
ref={columnRef}
className={cn("animate-marquee space-y-8 py-4", className)}
style={{ "--marquee-duration": duration } as React.CSSProperties}
>
{children}
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
"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>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Image from "next/image";
import React, { useState } from "react";
import {
motion,
useTransform,
AnimatePresence,
useMotionValue,
useSpring,
} from "framer-motion";
export const AnimatedTooltip = ({
items,
}: {
items: {
id: number;
name: string;
designation: string;
image: string;
}[];
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const springConfig = { stiffness: 100, damping: 5 };
const x = useMotionValue(0); // going to set this value on mouse move
// rotate the tooltip
const rotate = useSpring(
useTransform(x, [-100, 100], [-45, 45]),
springConfig
);
// translate the tooltip
const translateX = useSpring(
useTransform(x, [-100, 100], [-50, 50]),
springConfig
);
const handleMouseMove = (event: any) => {
const halfWidth = event.target.offsetWidth / 2;
x.set(event.nativeEvent.offsetX - halfWidth); // set the x value, which is then used in transform and rotate
};
return (
<>
{items.map((item) => (
<div
className="-mr-4 relative group"
key={item.name}
onMouseEnter={() => setHoveredIndex(item.id)}
onMouseLeave={() => setHoveredIndex(null)}
>
<AnimatePresence>
{hoveredIndex === item.id && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.6 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring",
stiffness: 260,
damping: 10,
},
}}
exit={{ opacity: 0, y: 20, scale: 0.6 }}
style={{
translateX: translateX,
rotate: rotate,
whiteSpace: "nowrap",
}}
className="absolute -top-16 -left-1/2 translate-x-1/2 flex text-xs flex-col items-center justify-center rounded-md bg-popover text-popover-foreground z-50 shadow-xl px-4 py-2 border border-border"
>
<div className="absolute inset-x-10 z-30 w-[20%] -bottom-px bg-gradient-to-r from-transparent via-emerald-500 to-transparent h-px " />
<div className="absolute left-10 w-[40%] z-30 -bottom-px bg-gradient-to-r from-transparent via-sky-500 to-transparent h-px " />
<div className="font-bold text-popover-foreground relative z-30 text-base">
{item.name}
</div>
<div className="text-popover-foreground text-xs">{item.designation}</div>
</motion.div>
)}
</AnimatePresence>
<Image
onMouseMove={handleMouseMove}
height={100}
width={100}
src={item.image}
alt={item.name}
unoptimized
className="object-cover !m-0 !p-0 object-top rounded-full h-14 w-14 border-2 group-hover:scale-105 group-hover:z-30 border-border relative transition duration-500"
/>
</div>
))}
</>
);
};
+181
View File
@@ -0,0 +1,181 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(
"text-[#9393A3] pb-0 text-sm",
error && "text-destructive",
className
)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-xs font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
@@ -0,0 +1,73 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useState } from "react";
export const InfiniteMovingCards = ({
direction = "left",
speed = "fast",
pauseOnHover = true,
children,
}: {
direction?: "left" | "right";
speed?: "fast" | "normal" | "slow";
pauseOnHover?: boolean;
children: React.ReactNode;
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const hasInitialized = React.useRef(false);
const [start, setStart] = useState(false);
useEffect(() => {
if (!containerRef.current || !scrollerRef.current || hasInitialized.current) {
return;
}
const scrollerContent = Array.from(scrollerRef.current.children);
scrollerContent.forEach((item) => {
const duplicatedItem = item.cloneNode(true);
scrollerRef.current?.appendChild(duplicatedItem);
});
hasInitialized.current = true;
setStart(true);
}, []);
useEffect(() => {
if (!containerRef.current) {
return;
}
const animationDirection = direction === "left" ? "forwards" : "reverse";
containerRef.current.style.setProperty("--animation-direction", animationDirection);
}, [direction]);
useEffect(() => {
if (!containerRef.current) {
return;
}
const duration = speed === "fast" ? "20s" : speed === "normal" ? "40s" : "80s";
containerRef.current.style.setProperty("--animation-duration", duration);
}, [speed]);
return (
<div
ref={containerRef}
className={cn(
"scroller relative z-20 group max-w-7xl overflow-hidden [mask-image:linear-gradient(to_right,transparent,white_20%,white_80%,transparent)]"
)}
>
<div
ref={scrollerRef}
className={cn(
" flex min-w-full shrink-0 gap-4 py-4 w-max flex-nowrap",
start && "animate-scroll [animation-play-state:running]",
pauseOnHover && "group-hover:[animation-play-state:paused]"
)}
>
{children}
</div>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };