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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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})`} /> */
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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're done, they sit down and
|
||||
drink beer.
|
||||
</UserMessage>
|
||||
<AIMessage>
|
||||
Certainly, I'm generating this picture for you in a while. BTW
|
||||
are you talking about THAT movie?
|
||||
</AIMessage>
|
||||
<UserMessage>
|
||||
I don't know what you're talking about.
|
||||
</UserMessage>
|
||||
<AIMessage>Are you sure?</AIMessage>
|
||||
<UserMessage>
|
||||
Yes, I'm sure. But if you're generating that scene, make
|
||||
sure the fighters have clown shoes and rubber chickens instead of
|
||||
fists!
|
||||
</UserMessage>
|
||||
<AIMessage>Affirmative, here'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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't you do it yourself?</AIMessage>
|
||||
<UserMessage>
|
||||
Umm.. Because I'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' 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user