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,28 @@
|
||||
import GoogleAnalytics from "./google-analytics";
|
||||
import ClarityAnalytics from "./clarity-analytics";
|
||||
import { analyticsConfig } from "@/constants/website";
|
||||
|
||||
export type AnalyticsProps = {
|
||||
readonly forceEnableInDevelopment?: boolean;
|
||||
};
|
||||
|
||||
export function Analytics({ forceEnableInDevelopment = false }: AnalyticsProps = {}) {
|
||||
const { enableInDevelopment } = analyticsConfig;
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
!enableInDevelopment &&
|
||||
!forceEnableInDevelopment
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalytics />
|
||||
<ClarityAnalytics />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Analytics;
|
||||
@@ -0,0 +1,30 @@
|
||||
import Script from "next/script";
|
||||
|
||||
export type ClarityAnalyticsProps = {
|
||||
readonly projectId?: string;
|
||||
};
|
||||
|
||||
export default function ClarityAnalytics({ projectId }: ClarityAnalyticsProps = {}) {
|
||||
const clarityProjectId = projectId ?? process.env.NEXT_PUBLIC_CLARITY_PROJECT_ID;
|
||||
|
||||
if (!clarityProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedId = clarityProjectId.trim();
|
||||
|
||||
return (
|
||||
<Script id="microsoft-clarity" strategy="afterInteractive">
|
||||
{`
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);
|
||||
t.async=1;
|
||||
t.src="https://www.clarity.ms/tag/${sanitizedId}";
|
||||
y=l.getElementsByTagName(r)[0];
|
||||
y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "${sanitizedId}");
|
||||
`}
|
||||
</Script>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Script from "next/script";
|
||||
|
||||
export type GoogleAnalyticsProps = {
|
||||
readonly trackingId?: string;
|
||||
};
|
||||
|
||||
export default function GoogleAnalytics({ trackingId }: GoogleAnalyticsProps = {}) {
|
||||
const gaTrackingId = trackingId ?? process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;
|
||||
|
||||
if (!gaTrackingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedId = gaTrackingId.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(sanitizedId)}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${sanitizedId}', { anonymize_ip: true });
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { CategoriesTable } from "@/features/tools/components/categories-table";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export default async function AdminCategoriesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const [categories, t] = await Promise.all([
|
||||
getCategoriesWithCount(),
|
||||
getTranslations({ locale, namespace: "Admin.categoriesManagement" }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t("title")}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("toolCountLabel", { count: categories.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CategoriesTable categories={categories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { AdminDashboard } from "@/features/admin/components/admin-dashboard";
|
||||
|
||||
export default async function AdminPage() {
|
||||
// 获取统计数据
|
||||
const [totalUsers, activeUsers] = await Promise.all([
|
||||
// 总用户数
|
||||
db.select({ count: sql<number>`count(*)` }).from(user),
|
||||
|
||||
// 活跃用户数(30天内)
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(user)
|
||||
.where(sql`${user.updatedAt} > NOW() - INTERVAL '30 days'`),
|
||||
]);
|
||||
|
||||
// 获取最近的用户
|
||||
const recentUsers = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(sql`${user.createdAt} desc`)
|
||||
.limit(5);
|
||||
|
||||
const stats = {
|
||||
totalUsers: totalUsers[0].count,
|
||||
activeUsers: activeUsers[0].count,
|
||||
};
|
||||
|
||||
return <AdminDashboard stats={stats} recentUsers={recentUsers} />;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getTagsWithCount } from "@/features/tools/actions";
|
||||
import { TagsTable } from "@/features/tools/components/tags-table";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export default async function AdminTagsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const [tags, t] = await Promise.all([
|
||||
getTagsWithCount(),
|
||||
getTranslations({ locale, namespace: "Admin.tagsManagement" }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t("title")}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("toolCountLabel", { count: tags.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagsTable tags={tags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getToolsAdmin } from "@/features/tools/actions";
|
||||
import { ToolsTable } from "@/features/tools/components/tools-table";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export default async function AdminToolsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const [{ tools, total }, t] = await Promise.all([
|
||||
getToolsAdmin({ pageSize: 100 }),
|
||||
getTranslations({ locale, namespace: "Admin.toolsManagement" }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t("title")}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">{t("totalCount", { count: total })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolsTable tools={tools} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { UsersTable } from "@/features/admin/components/users-table";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
// 获取所有用户
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
role: user.role,
|
||||
banned: user.banned,
|
||||
banReason: user.banReason,
|
||||
banExpires: user.banExpires,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(sql`${user.createdAt} desc`);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">用户管理</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {users.length} 个用户
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsersTable users={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { requireAdmin } from "@/lib/auth/admin";
|
||||
import { AdminSidebar } from "@/features/admin/components/admin-sidebar";
|
||||
import { AdminHeader } from "@/features/admin/components/admin-header";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// 检查管理员权限
|
||||
await requireAdmin();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="flex">
|
||||
{/* 侧边栏 */}
|
||||
<AdminSidebar />
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<AdminHeader />
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/components/button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { FormShell } from "@/features/forms/components/form-shell";
|
||||
import { AuthMessageCard } from "@/features/forms/components/auth-message-card";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/features/forms/components/input";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
});
|
||||
|
||||
type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
|
||||
const form = useForm<ForgotPasswordInput>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordInput) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setEmailSent(true);
|
||||
toast.success("Password reset email sent!");
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.message || "Failed to send reset email");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<AuthMessageCard
|
||||
title="Check your email"
|
||||
description="We've sent you a password reset link. Please check your email."
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Didn't receive an email? Check your spam folder or try again.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<LocaleLink href="/login">
|
||||
<Button variant="simple" className="w-full">
|
||||
Back to Login
|
||||
</Button>
|
||||
</LocaleLink>
|
||||
</AuthMessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormShell<ForgotPasswordInput>
|
||||
form={form}
|
||||
title="Forgot password?"
|
||||
description="Enter your email address and we'll send you a reset link."
|
||||
onSubmit={onSubmit}
|
||||
submitText="Send Reset Link"
|
||||
submitLoadingText="Sending..."
|
||||
isLoading={isLoading}
|
||||
footer={
|
||||
<div className="text-center">
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Remember your password? Sign in
|
||||
</LocaleLink>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AuthLayout } from "@/layouts/auth-layout";
|
||||
export default function AuthXLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AuthLayout>
|
||||
<main className="flex h-full min-h-screen w-full">{children}</main>
|
||||
</AuthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { LoginForm } from "@/features/auth/components/login-form";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('login.title'),
|
||||
description: t('login.description'),
|
||||
openGraph: {
|
||||
images: [t('login.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/components/button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { FormShell } from "@/features/forms/components/form-shell";
|
||||
import { AuthMessageCard } from "@/features/forms/components/auth-message-card";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/features/forms/components/input";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidToken, setIsValidToken] = useState<boolean | null>(null);
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const form = useForm<ResetPasswordInput>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setIsValidToken(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token validity
|
||||
fetch(`/api/auth/verify-reset-token?token=${token}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setIsValidToken(data.valid);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsValidToken(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const onSubmit = async (data: ResetPasswordInput) => {
|
||||
if (!token) {
|
||||
toast.error("Invalid reset link");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Password reset successfully!");
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 2000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.message || "Failed to reset password");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isValidToken === null) {
|
||||
return (
|
||||
<AuthMessageCard
|
||||
title="Verifying..."
|
||||
description="Please wait while we verify your reset link."
|
||||
>
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground"></div>
|
||||
</div>
|
||||
</AuthMessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isValidToken === false) {
|
||||
return (
|
||||
<AuthMessageCard
|
||||
title="Invalid or Expired Link"
|
||||
description="This password reset link is invalid or has expired."
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please request a new password reset link.
|
||||
</p>
|
||||
<LocaleLink href="/forgot-password">
|
||||
<Button className="w-full">
|
||||
Request New Link
|
||||
</Button>
|
||||
</LocaleLink>
|
||||
<LocaleLink href="/login">
|
||||
<Button variant="outline" className="w-full">
|
||||
Back to Login
|
||||
</Button>
|
||||
</LocaleLink>
|
||||
</AuthMessageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormShell<ResetPasswordInput>
|
||||
form={form}
|
||||
title="Reset your password"
|
||||
description="Enter your new password below."
|
||||
onSubmit={onSubmit}
|
||||
submitText="Reset Password"
|
||||
submitLoadingText="Resetting..."
|
||||
isLoading={isLoading}
|
||||
footer={
|
||||
<div className="text-center">
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Back to Login
|
||||
</LocaleLink>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
disabled={isLoading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('signup.title'),
|
||||
description: t('signup.description'),
|
||||
openGraph: {
|
||||
images: [t('signup.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
return <SignupForm />;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getAllBlogs, getBlogModule } from "@/lib/blog";
|
||||
import { locales, type Locale } from "@/i18n.config";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: Locale;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const blogs = await getAllBlogs();
|
||||
|
||||
return blogs.flatMap((blog) =>
|
||||
locales.map((locale) => ({
|
||||
slug: blog.slug,
|
||||
locale,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
|
||||
const blogModule = await getBlogModule(slug, locale);
|
||||
|
||||
if (!blogModule) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { blog } = blogModule;
|
||||
const metadata: Metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
};
|
||||
|
||||
if (blog.image) {
|
||||
metadata.openGraph = {
|
||||
images: [blog.image],
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug, locale } = await params;
|
||||
|
||||
const blogModule = await getBlogModule(slug, locale);
|
||||
|
||||
if (!blogModule) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const MDXContent = blogModule.default;
|
||||
|
||||
return <MDXContent />;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-28",
|
||||
title: "AI Chatbots for Business: ChatGPT, Claude, and Gemini Compared",
|
||||
description:
|
||||
"A detailed comparison of enterprise AI chatbots. Learn which AI assistant is best for your business needs, from customer service to content creation.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The Business AI Assistant Revolution
|
||||
|
||||
AI chatbots have evolved from simple FAQ bots to sophisticated business partners. Today's enterprise AI assistants can draft emails, analyze data, write code, and even help with strategic decisions.
|
||||
|
||||
## The Big Three: An Overview
|
||||
|
||||
### ChatGPT (OpenAI)
|
||||
|
||||
**Best for:** General-purpose business tasks, content creation, coding assistance
|
||||
|
||||
ChatGPT remains the most widely adopted AI assistant in the business world. With GPT-4 Turbo and the new GPT-4o model, it offers:
|
||||
|
||||
- **128K context window** - Process entire documents at once
|
||||
- **Multimodal capabilities** - Analyze images and charts
|
||||
- **Custom GPTs** - Build specialized assistants for your team
|
||||
- **API access** - Integrate into your existing workflows
|
||||
|
||||
**Pricing:** $20/month (Plus) or $25/user/month (Team)
|
||||
|
||||
### Claude (Anthropic)
|
||||
|
||||
**Best for:** Long document analysis, nuanced writing, safety-critical applications
|
||||
|
||||
Claude has carved out a niche for businesses requiring thoughtful, nuanced AI interactions:
|
||||
|
||||
- **200K context window** - Industry-leading document processing
|
||||
- **Constitutional AI** - Built-in safety and ethics guidelines
|
||||
- **Excellent at following complex instructions**
|
||||
- **Strong coding and analysis capabilities**
|
||||
|
||||
**Pricing:** $20/month (Pro) or custom enterprise pricing
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
**Best for:** Google Workspace integration, research, real-time information
|
||||
|
||||
Google's Gemini offers unique advantages for businesses in the Google ecosystem:
|
||||
|
||||
- **Deep Google integration** - Works with Docs, Sheets, Gmail
|
||||
- **Real-time information** - Access to current web data
|
||||
- **Multimodal from the ground up** - Native image and video understanding
|
||||
- **Competitive pricing** - Generous free tier
|
||||
|
||||
**Pricing:** $20/month (Advanced) or included with Workspace plans
|
||||
|
||||
## Head-to-Head Comparison
|
||||
|
||||
| Feature | ChatGPT | Claude | Gemini |
|
||||
|---------|---------|--------|--------|
|
||||
| Context Window | 128K | 200K | 1M (Gemini 1.5) |
|
||||
| Coding | Excellent | Very Good | Good |
|
||||
| Writing | Very Good | Excellent | Good |
|
||||
| Analysis | Very Good | Excellent | Very Good |
|
||||
| Integration | API-focused | API-focused | Google-native |
|
||||
| Custom Training | Yes (GPTs) | Limited | Limited |
|
||||
|
||||
## Use Case Recommendations
|
||||
|
||||
### Customer Support
|
||||
**Winner: ChatGPT** - Best ecosystem for building custom support bots
|
||||
|
||||
### Content Marketing
|
||||
**Winner: Claude** - Superior long-form writing quality
|
||||
|
||||
### Data Analysis
|
||||
**Winner: Gemini** - Seamless Google Sheets integration
|
||||
|
||||
### Software Development
|
||||
**Winner: ChatGPT** - Most comprehensive coding assistance
|
||||
|
||||
### Research & Analysis
|
||||
**Winner: Claude** - Best for processing lengthy documents
|
||||
|
||||
## Implementation Tips
|
||||
|
||||
1. **Start with a pilot** - Test with a small team before company-wide rollout
|
||||
2. **Define clear use cases** - Know what problems you're solving
|
||||
3. **Train your team** - Invest in prompt engineering education
|
||||
4. **Monitor usage** - Track ROI and identify optimization opportunities
|
||||
5. **Stay compliant** - Ensure AI use aligns with data privacy regulations
|
||||
|
||||
## Conclusion
|
||||
|
||||
There's no one-size-fits-all AI chatbot for business. Your choice should depend on your specific needs, existing tech stack, and budget. Many businesses find value in using multiple AI assistants for different tasks.
|
||||
|
||||
Explore our complete directory of AI chatbots and assistants on Toolso.AI to find the perfect fit for your organization.
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-28",
|
||||
title: "企业级AI聊天机器人对比:ChatGPT、Claude和Gemini",
|
||||
description:
|
||||
"深入对比企业级AI聊天机器人。了解哪款AI助手最适合您的业务需求,从客户服务到内容创作。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1531746790731-6c087fecd65a?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## 商业AI助手的革命
|
||||
|
||||
AI聊天机器人已从简单的FAQ机器人演变为成熟的商业伙伴。如今的企业级AI助手可以起草邮件、分析数据、编写代码,甚至协助战略决策。
|
||||
|
||||
## 三巨头概览
|
||||
|
||||
### ChatGPT (OpenAI)
|
||||
|
||||
**最适合:** 通用商业任务、内容创作、编程辅助
|
||||
|
||||
ChatGPT仍然是商业领域最广泛采用的AI助手。凭借GPT-4 Turbo和新的GPT-4o模型,它提供:
|
||||
|
||||
- **128K上下文窗口** - 一次处理整个文档
|
||||
- **多模态能力** - 分析图像和图表
|
||||
- **自定义GPTs** - 为团队构建专属助手
|
||||
- **API访问** - 集成到现有工作流程
|
||||
|
||||
**定价:** $20/月(Plus)或 $25/用户/月(Team)
|
||||
|
||||
### Claude (Anthropic)
|
||||
|
||||
**最适合:** 长文档分析、细腻写作、安全关键应用
|
||||
|
||||
Claude为需要深思熟虑、细腻AI交互的企业开辟了一片天地:
|
||||
|
||||
- **200K上下文窗口** - 行业领先的文档处理能力
|
||||
- **宪法AI** - 内置安全和道德准则
|
||||
- **擅长遵循复杂指令**
|
||||
- **强大的编码和分析能力**
|
||||
|
||||
**定价:** $20/月(Pro)或定制企业定价
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
**最适合:** Google Workspace集成、研究、实时信息
|
||||
|
||||
Google的Gemini为Google生态系统中的企业提供独特优势:
|
||||
|
||||
- **深度Google集成** - 与Docs、Sheets、Gmail协同工作
|
||||
- **实时信息** - 访问当前网络数据
|
||||
- **原生多模态** - 原生图像和视频理解
|
||||
- **有竞争力的定价** - 慷慨的免费层级
|
||||
|
||||
**定价:** $20/月(Advanced)或包含在Workspace计划中
|
||||
|
||||
## 正面对比
|
||||
|
||||
| 特性 | ChatGPT | Claude | Gemini |
|
||||
|------|---------|--------|--------|
|
||||
| 上下文窗口 | 128K | 200K | 1M (Gemini 1.5) |
|
||||
| 编程 | 优秀 | 很好 | 好 |
|
||||
| 写作 | 很好 | 优秀 | 好 |
|
||||
| 分析 | 很好 | 优秀 | 很好 |
|
||||
| 集成 | 以API为中心 | 以API为中心 | Google原生 |
|
||||
| 自定义训练 | 是(GPTs) | 有限 | 有限 |
|
||||
|
||||
## 使用场景推荐
|
||||
|
||||
### 客户支持
|
||||
**推荐:ChatGPT** - 构建自定义支持机器人的最佳生态系统
|
||||
|
||||
### 内容营销
|
||||
**推荐:Claude** - 卓越的长篇写作质量
|
||||
|
||||
### 数据分析
|
||||
**推荐:Gemini** - 与Google Sheets无缝集成
|
||||
|
||||
### 软件开发
|
||||
**推荐:ChatGPT** - 最全面的编码辅助
|
||||
|
||||
### 研究与分析
|
||||
**推荐:Claude** - 处理长篇文档的最佳选择
|
||||
|
||||
## 实施建议
|
||||
|
||||
1. **从试点开始** - 在全公司推广前先用小团队测试
|
||||
2. **明确使用场景** - 清楚您要解决什么问题
|
||||
3. **培训团队** - 投资提示词工程教育
|
||||
4. **监控使用情况** - 跟踪ROI并识别优化机会
|
||||
5. **保持合规** - 确保AI使用符合数据隐私法规
|
||||
|
||||
## 结论
|
||||
|
||||
没有一款AI聊天机器人能适用于所有企业。您的选择应取决于具体需求、现有技术栈和预算。许多企业发现,针对不同任务使用多个AI助手更有价值。
|
||||
|
||||
在 Toolso.AI 浏览我们完整的AI聊天机器人和助手目录,找到最适合您组织的工具。
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-10",
|
||||
title: "AI Code Assistants: GitHub Copilot vs Cursor vs Codeium",
|
||||
description:
|
||||
"The ultimate guide to AI coding assistants. Compare features, pricing, and real-world performance to find the best AI pair programmer for your development workflow.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The AI Coding Revolution
|
||||
|
||||
AI code assistants have evolved from simple autocomplete to intelligent pair programmers. In 2025, they're not just nice-to-have - they're essential tools for competitive developers.
|
||||
|
||||
## The Big Three Compared
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
**Developer:** GitHub (Microsoft)
|
||||
|
||||
**Model:** GPT-4 based (Copilot X)
|
||||
|
||||
**Key Features:**
|
||||
- Inline code completion
|
||||
- Chat interface for explanations
|
||||
- Pull request summaries
|
||||
- Documentation generation
|
||||
- CLI assistance
|
||||
|
||||
**Supported IDEs:**
|
||||
- VS Code (best support)
|
||||
- JetBrains IDEs
|
||||
- Neovim
|
||||
- Visual Studio
|
||||
|
||||
**Strengths:**
|
||||
- Largest training dataset (GitHub's entire codebase)
|
||||
- Excellent context understanding
|
||||
- Strong enterprise features
|
||||
- Active development and updates
|
||||
|
||||
**Weaknesses:**
|
||||
- Privacy concerns for enterprise
|
||||
- Subscription required
|
||||
- Can suggest copyrighted code
|
||||
|
||||
**Pricing:**
|
||||
- Individual: $10/month
|
||||
- Business: $19/user/month
|
||||
- Enterprise: Custom pricing
|
||||
|
||||
### Cursor
|
||||
|
||||
**Developer:** Cursor Inc.
|
||||
|
||||
**Model:** GPT-4 + Claude
|
||||
|
||||
**Key Features:**
|
||||
- Full IDE built around AI
|
||||
- Codebase-aware chat
|
||||
- AI-powered refactoring
|
||||
- Multi-file editing
|
||||
- Natural language code generation
|
||||
|
||||
**Supported IDEs:**
|
||||
- Cursor (standalone, VS Code fork)
|
||||
|
||||
**Strengths:**
|
||||
- Best codebase understanding
|
||||
- Powerful chat capabilities
|
||||
- Multi-file context
|
||||
- Fast iteration on features
|
||||
|
||||
**Weaknesses:**
|
||||
- Requires switching IDE
|
||||
- Newer, less battle-tested
|
||||
- Limited to their IDE
|
||||
|
||||
**Pricing:**
|
||||
- Hobby: Free (limited)
|
||||
- Pro: $20/month
|
||||
- Business: $40/user/month
|
||||
|
||||
### Codeium
|
||||
|
||||
**Developer:** Exafunction
|
||||
|
||||
**Model:** Proprietary
|
||||
|
||||
**Key Features:**
|
||||
- Unlimited autocomplete
|
||||
- In-editor chat
|
||||
- Search functionality
|
||||
- Multi-language support
|
||||
- Self-hosted option
|
||||
|
||||
**Supported IDEs:**
|
||||
- VS Code
|
||||
- JetBrains IDEs
|
||||
- Neovim/Vim
|
||||
- Emacs
|
||||
- 40+ editors
|
||||
|
||||
**Strengths:**
|
||||
- Generous free tier
|
||||
- Wide IDE support
|
||||
- Fast completions
|
||||
- Privacy-focused options
|
||||
|
||||
**Weaknesses:**
|
||||
- Less sophisticated than GPT-4 based tools
|
||||
- Chat not as powerful
|
||||
- Smaller community
|
||||
|
||||
**Pricing:**
|
||||
- Individual: Free (unlimited)
|
||||
- Teams: $12/user/month
|
||||
- Enterprise: Custom
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
| Feature | Copilot | Cursor | Codeium |
|
||||
|---------|---------|--------|---------|
|
||||
| Code Completion | ★★★★★ | ★★★★☆ | ★★★★☆ |
|
||||
| Chat Quality | ★★★★☆ | ★★★★★ | ★★★☆☆ |
|
||||
| Codebase Context | ★★★☆☆ | ★★★★★ | ★★★☆☆ |
|
||||
| IDE Support | ★★★★☆ | ★★☆☆☆ | ★★★★★ |
|
||||
| Free Tier | ★☆☆☆☆ | ★★☆☆☆ | ★★★★★ |
|
||||
| Enterprise Ready | ★★★★★ | ★★★☆☆ | ★★★★☆ |
|
||||
|
||||
## Other Notable AI Coding Tools
|
||||
|
||||
### Amazon CodeWhisperer
|
||||
- Free for individual use
|
||||
- Best for AWS development
|
||||
- Security scanning included
|
||||
|
||||
### Tabnine
|
||||
- Privacy-focused
|
||||
- On-premise option
|
||||
- Team learning features
|
||||
|
||||
### Sourcegraph Cody
|
||||
- Excellent code search
|
||||
- Open source friendly
|
||||
- Strong enterprise features
|
||||
|
||||
### Replit AI
|
||||
- Browser-based development
|
||||
- Great for learning
|
||||
- Integrated deployment
|
||||
|
||||
## Choosing the Right Tool
|
||||
|
||||
### Choose Copilot If:
|
||||
- You're in the Microsoft/GitHub ecosystem
|
||||
- Enterprise compliance is critical
|
||||
- You want the most mature solution
|
||||
- VS Code is your primary IDE
|
||||
|
||||
### Choose Cursor If:
|
||||
- You want the best AI-native experience
|
||||
- Large codebase understanding is crucial
|
||||
- You're open to switching IDEs
|
||||
- You value cutting-edge features
|
||||
|
||||
### Choose Codeium If:
|
||||
- Budget is a primary concern
|
||||
- You use multiple IDEs
|
||||
- Privacy is paramount
|
||||
- You want unlimited free usage
|
||||
|
||||
## Productivity Impact
|
||||
|
||||
Real-world studies show:
|
||||
|
||||
- **55% faster** task completion (GitHub research)
|
||||
- **40% less** context switching
|
||||
- **30% fewer** bugs in initial code
|
||||
- **2x faster** onboarding to new codebases
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Learn the shortcuts** - Speed comes from muscle memory
|
||||
2. **Review all suggestions** - AI makes mistakes
|
||||
3. **Write good comments** - Better prompts = better code
|
||||
4. **Use chat for explanations** - Faster than documentation
|
||||
5. **Iterate with AI** - Let it refine your code
|
||||
|
||||
## Privacy Considerations
|
||||
|
||||
| Tool | Code Retention | Training on Your Code | Self-Hosted |
|
||||
|------|----------------|----------------------|-------------|
|
||||
| Copilot | Optional | Business: No | No |
|
||||
| Cursor | Minimal | No | No |
|
||||
| Codeium | None | No | Yes |
|
||||
|
||||
## Conclusion
|
||||
|
||||
AI code assistants are the biggest productivity boost for developers since the IDE. Whether you choose Copilot's maturity, Cursor's innovation, or Codeium's accessibility, you'll write better code faster.
|
||||
|
||||
Explore our complete collection of AI development tools on Toolso.AI to find the perfect coding companion.
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-10",
|
||||
title: "AI编程助手对比:GitHub Copilot vs Cursor vs Codeium",
|
||||
description:
|
||||
"AI编程助手终极指南。比较功能、价格和实际表现,找到最适合你开发工作流的AI结对编程伙伴。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI编程革命
|
||||
|
||||
AI编程助手已从简单的自动补全演变为智能结对编程伙伴。在2025年,它们不再是可有可无——而是有竞争力的开发者必备的工具。
|
||||
|
||||
## 三大工具对比
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
**开发者:** GitHub (Microsoft)
|
||||
|
||||
**模型:** 基于GPT-4 (Copilot X)
|
||||
|
||||
**主要功能:**
|
||||
- 行内代码补全
|
||||
- 聊天界面解释代码
|
||||
- Pull Request摘要
|
||||
- 文档生成
|
||||
- CLI辅助
|
||||
|
||||
**支持的IDE:**
|
||||
- VS Code(最佳支持)
|
||||
- JetBrains IDE系列
|
||||
- Neovim
|
||||
- Visual Studio
|
||||
|
||||
**优势:**
|
||||
- 最大的训练数据集(GitHub全部代码库)
|
||||
- 出色的上下文理解
|
||||
- 强大的企业功能
|
||||
- 积极的开发和更新
|
||||
|
||||
**劣势:**
|
||||
- 企业隐私顾虑
|
||||
- 需要订阅
|
||||
- 可能建议受版权保护的代码
|
||||
|
||||
**价格:**
|
||||
- 个人版:$10/月
|
||||
- 商业版:$19/用户/月
|
||||
- 企业版:定制价格
|
||||
|
||||
### Cursor
|
||||
|
||||
**开发者:** Cursor Inc.
|
||||
|
||||
**模型:** GPT-4 + Claude
|
||||
|
||||
**主要功能:**
|
||||
- 围绕AI构建的完整IDE
|
||||
- 代码库感知聊天
|
||||
- AI驱动的重构
|
||||
- 多文件编辑
|
||||
- 自然语言生成代码
|
||||
|
||||
**支持的IDE:**
|
||||
- Cursor(独立版,VS Code分支)
|
||||
|
||||
**优势:**
|
||||
- 最佳代码库理解
|
||||
- 强大的聊天能力
|
||||
- 多文件上下文
|
||||
- 功能快速迭代
|
||||
|
||||
**劣势:**
|
||||
- 需要切换IDE
|
||||
- 较新,经验较少
|
||||
- 仅限于其IDE
|
||||
|
||||
**价格:**
|
||||
- 爱好者版:免费(有限制)
|
||||
- Pro版:$20/月
|
||||
- 商业版:$40/用户/月
|
||||
|
||||
### Codeium
|
||||
|
||||
**开发者:** Exafunction
|
||||
|
||||
**模型:** 专有模型
|
||||
|
||||
**主要功能:**
|
||||
- 无限自动补全
|
||||
- 编辑器内聊天
|
||||
- 搜索功能
|
||||
- 多语言支持
|
||||
- 自托管选项
|
||||
|
||||
**支持的IDE:**
|
||||
- VS Code
|
||||
- JetBrains IDE系列
|
||||
- Neovim/Vim
|
||||
- Emacs
|
||||
- 40+编辑器
|
||||
|
||||
**优势:**
|
||||
- 慷慨的免费层级
|
||||
- 广泛的IDE支持
|
||||
- 快速补全
|
||||
- 注重隐私的选项
|
||||
|
||||
**劣势:**
|
||||
- 不如基于GPT-4的工具复杂
|
||||
- 聊天功能不够强大
|
||||
- 社区较小
|
||||
|
||||
**价格:**
|
||||
- 个人版:免费(无限制)
|
||||
- 团队版:$12/用户/月
|
||||
- 企业版:定制
|
||||
|
||||
## 功能对比
|
||||
|
||||
| 功能 | Copilot | Cursor | Codeium |
|
||||
|------|---------|--------|---------|
|
||||
| 代码补全 | ★★★★★ | ★★★★☆ | ★★★★☆ |
|
||||
| 聊天质量 | ★★★★☆ | ★★★★★ | ★★★☆☆ |
|
||||
| 代码库上下文 | ★★★☆☆ | ★★★★★ | ★★★☆☆ |
|
||||
| IDE支持 | ★★★★☆ | ★★☆☆☆ | ★★★★★ |
|
||||
| 免费层级 | ★☆☆☆☆ | ★★☆☆☆ | ★★★★★ |
|
||||
| 企业就绪 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
|
||||
|
||||
## 其他值得关注的AI编程工具
|
||||
|
||||
### Amazon CodeWhisperer
|
||||
- 个人使用免费
|
||||
- 最适合AWS开发
|
||||
- 包含安全扫描
|
||||
|
||||
### Tabnine
|
||||
- 注重隐私
|
||||
- 本地部署选项
|
||||
- 团队学习功能
|
||||
|
||||
### Sourcegraph Cody
|
||||
- 出色的代码搜索
|
||||
- 开源友好
|
||||
- 强大的企业功能
|
||||
|
||||
### Replit AI
|
||||
- 基于浏览器的开发
|
||||
- 适合学习
|
||||
- 集成部署
|
||||
|
||||
## 选择正确的工具
|
||||
|
||||
### 选择Copilot如果:
|
||||
- 你在Microsoft/GitHub生态系统中
|
||||
- 企业合规至关重要
|
||||
- 你想要最成熟的解决方案
|
||||
- VS Code是你的主要IDE
|
||||
|
||||
### 选择Cursor如果:
|
||||
- 你想要最佳AI原生体验
|
||||
- 大型代码库理解至关重要
|
||||
- 你愿意切换IDE
|
||||
- 你重视前沿功能
|
||||
|
||||
### 选择Codeium如果:
|
||||
- 预算是主要考虑因素
|
||||
- 你使用多个IDE
|
||||
- 隐私至上
|
||||
- 你想要无限免费使用
|
||||
|
||||
## 生产力影响
|
||||
|
||||
实际研究表明:
|
||||
|
||||
- **55%更快**完成任务(GitHub研究)
|
||||
- **40%更少**上下文切换
|
||||
- **30%更少**初始代码bug
|
||||
- **2倍更快**熟悉新代码库
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **学习快捷键** - 速度来自肌肉记忆
|
||||
2. **审查所有建议** - AI会犯错
|
||||
3. **写好注释** - 更好的提示 = 更好的代码
|
||||
4. **用聊天获取解释** - 比查文档更快
|
||||
5. **与AI迭代** - 让它优化你的代码
|
||||
|
||||
## 隐私考虑
|
||||
|
||||
| 工具 | 代码保留 | 用你的代码训练 | 自托管 |
|
||||
|------|----------|----------------|--------|
|
||||
| Copilot | 可选 | 商业版:否 | 否 |
|
||||
| Cursor | 最小化 | 否 | 否 |
|
||||
| Codeium | 无 | 否 | 是 |
|
||||
|
||||
## 结论
|
||||
|
||||
AI编程助手是自IDE以来开发者最大的生产力提升。无论你选择Copilot的成熟度、Cursor的创新性还是Codeium的可及性,你都会更快地写出更好的代码。
|
||||
|
||||
在 Toolso.AI 探索我们完整的AI开发工具集合,找到完美的编程伙伴。
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-02",
|
||||
title: "AI Image Generators Compared: Midjourney vs DALL-E vs Stable Diffusion",
|
||||
description:
|
||||
"A comprehensive comparison of the top AI image generators. Learn the strengths and weaknesses of each platform to choose the best one for your creative needs.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1547954575-855750c57bd3?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The Rise of AI Image Generation
|
||||
|
||||
AI image generation has evolved from a novelty to an essential creative tool. In this guide, we compare the three leading platforms to help you make an informed choice.
|
||||
|
||||
## Midjourney
|
||||
|
||||
### Strengths
|
||||
- **Artistic quality** - Produces stunning, artistic images with minimal effort
|
||||
- **Community** - Active Discord community for inspiration
|
||||
- **Consistency** - Reliable style and quality across generations
|
||||
|
||||
### Weaknesses
|
||||
- Discord-only interface (web app in beta)
|
||||
- Limited fine control over outputs
|
||||
- Subscription required for all usage
|
||||
|
||||
### Best For
|
||||
Artists, designers, and anyone seeking high-quality artistic imagery.
|
||||
|
||||
## DALL-E 3
|
||||
|
||||
### Strengths
|
||||
- **Prompt understanding** - Best-in-class text comprehension
|
||||
- **Integration** - Built into ChatGPT and Microsoft products
|
||||
- **Text rendering** - Excellent at including text in images
|
||||
|
||||
### Weaknesses
|
||||
- More conservative content policies
|
||||
- Less stylistic flexibility
|
||||
- Credit-based pricing
|
||||
|
||||
### Best For
|
||||
Marketers, content creators, and those needing precise prompt adherence.
|
||||
|
||||
## Stable Diffusion
|
||||
|
||||
### Strengths
|
||||
- **Open source** - Run locally, modify freely
|
||||
- **Customization** - Extensive model fine-tuning options
|
||||
- **Cost** - Free to run on your own hardware
|
||||
|
||||
### Weaknesses
|
||||
- Steeper learning curve
|
||||
- Requires technical setup for best results
|
||||
- Quality depends on model and settings
|
||||
|
||||
### Best For
|
||||
Developers, researchers, and power users who want full control.
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | Midjourney | DALL-E 3 | Stable Diffusion |
|
||||
|---------|------------|----------|------------------|
|
||||
| Image Quality | Excellent | Very Good | Good to Excellent |
|
||||
| Ease of Use | Good | Excellent | Moderate |
|
||||
| Price | $10-60/mo | Pay per use | Free (self-hosted) |
|
||||
| Customization | Limited | Limited | Extensive |
|
||||
| Local Running | No | No | Yes |
|
||||
|
||||
## Conclusion
|
||||
|
||||
Each platform has its place in the AI image generation ecosystem. Consider your specific needs, technical comfort level, and budget when making your choice. Explore more AI image tools on Toolso.AI.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-02",
|
||||
title: "AI图像生成器对比:Midjourney vs DALL-E vs Stable Diffusion",
|
||||
description:
|
||||
"全面对比顶级AI图像生成器。了解每个平台的优缺点,选择最适合您创意需求的工具。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1547954575-855750c57bd3?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI图像生成的崛起
|
||||
|
||||
AI图像生成已经从新奇事物演变为必不可少的创意工具。在本指南中,我们对比三大领先平台,帮助您做出明智的选择。
|
||||
|
||||
## Midjourney
|
||||
|
||||
### 优势
|
||||
- **艺术质量** - 以最小的努力产出令人惊叹的艺术图像
|
||||
- **社区** - 活跃的Discord社区提供灵感
|
||||
- **一致性** - 跨代生成的风格和质量可靠
|
||||
|
||||
### 劣势
|
||||
- 仅支持Discord界面(网页应用处于测试阶段)
|
||||
- 对输出的精细控制有限
|
||||
- 所有使用都需要订阅
|
||||
|
||||
### 最适合
|
||||
艺术家、设计师,以及任何追求高质量艺术图像的人。
|
||||
|
||||
## DALL-E 3
|
||||
|
||||
### 优势
|
||||
- **提示词理解** - 业界最佳的文本理解能力
|
||||
- **集成** - 内置于ChatGPT和Microsoft产品中
|
||||
- **文字渲染** - 在图像中包含文字的效果极佳
|
||||
|
||||
### 劣势
|
||||
- 内容政策更为保守
|
||||
- 风格灵活性较低
|
||||
- 基于积分的定价
|
||||
|
||||
### 最适合
|
||||
营销人员、内容创作者,以及需要精确遵循提示词的用户。
|
||||
|
||||
## Stable Diffusion
|
||||
|
||||
### 优势
|
||||
- **开源** - 可本地运行,自由修改
|
||||
- **定制化** - 广泛的模型微调选项
|
||||
- **成本** - 在自己的硬件上免费运行
|
||||
|
||||
### 劣势
|
||||
- 学习曲线较陡
|
||||
- 需要技术设置才能获得最佳结果
|
||||
- 质量取决于模型和设置
|
||||
|
||||
### 最适合
|
||||
开发者、研究人员,以及想要完全控制的高级用户。
|
||||
|
||||
## 对比表格
|
||||
|
||||
| 特性 | Midjourney | DALL-E 3 | Stable Diffusion |
|
||||
|------|------------|----------|------------------|
|
||||
| 图像质量 | 优秀 | 很好 | 好到优秀 |
|
||||
| 易用性 | 好 | 优秀 | 中等 |
|
||||
| 价格 | $10-60/月 | 按使用付费 | 免费(自托管) |
|
||||
| 定制化 | 有限 | 有限 | 广泛 |
|
||||
| 本地运行 | 否 | 否 | 是 |
|
||||
|
||||
## 结论
|
||||
|
||||
每个平台在AI图像生成生态系统中都有其位置。在做出选择时,请考虑您的具体需求、技术熟练程度和预算。在 Toolso.AI 探索更多AI图像工具。
|
||||
@@ -0,0 +1,208 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-15",
|
||||
title: "10 AI Productivity Tools That Will Transform Your Workflow in 2025",
|
||||
description:
|
||||
"Supercharge your productivity with these game-changing AI tools. From email management to meeting summaries, discover how AI can save you hours every week.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI: Your New Productivity Partner
|
||||
|
||||
The average knowledge worker spends 60% of their time on "work about work" - emails, meetings, searching for information. AI tools are changing this dramatically, automating mundane tasks and freeing up time for meaningful work.
|
||||
|
||||
## Top 10 AI Productivity Tools
|
||||
|
||||
### 1. Notion AI
|
||||
|
||||
**What it does:** Integrated AI assistant within your workspace
|
||||
|
||||
**Key features:**
|
||||
- Summarize meeting notes instantly
|
||||
- Generate action items from documents
|
||||
- Write and edit content in context
|
||||
- Q&A over your entire workspace
|
||||
|
||||
**Time saved:** 3-5 hours/week
|
||||
|
||||
**Price:** Included with Notion plans ($10/month add-on for free users)
|
||||
|
||||
### 2. Otter.ai
|
||||
|
||||
**What it does:** AI meeting transcription and summarization
|
||||
|
||||
**Key features:**
|
||||
- Real-time transcription
|
||||
- Automatic meeting summaries
|
||||
- Action item extraction
|
||||
- Speaker identification
|
||||
|
||||
**Time saved:** 2-4 hours/week
|
||||
|
||||
**Price:** Free tier available, Pro at $16.99/month
|
||||
|
||||
### 3. Superhuman
|
||||
|
||||
**What it does:** AI-powered email client
|
||||
|
||||
**Key features:**
|
||||
- AI email writing assistance
|
||||
- Instant reply suggestions
|
||||
- Email prioritization
|
||||
- Scheduled send optimization
|
||||
|
||||
**Time saved:** 4-6 hours/week
|
||||
|
||||
**Price:** $30/month
|
||||
|
||||
### 4. Reclaim.ai
|
||||
|
||||
**What it does:** Intelligent calendar management
|
||||
|
||||
**Key features:**
|
||||
- Auto-schedule tasks and habits
|
||||
- Smart meeting scheduling
|
||||
- Focus time protection
|
||||
- Team coordination
|
||||
|
||||
**Time saved:** 2-3 hours/week
|
||||
|
||||
**Price:** Free tier available, Pro at $8/month
|
||||
|
||||
### 5. Mem
|
||||
|
||||
**What it does:** AI-powered note-taking and knowledge management
|
||||
|
||||
**Key features:**
|
||||
- Auto-organizes notes
|
||||
- Smart search across all content
|
||||
- AI-generated connections
|
||||
- Meeting integration
|
||||
|
||||
**Time saved:** 2-4 hours/week
|
||||
|
||||
**Price:** Free tier available, Pro at $15/month
|
||||
|
||||
### 6. Jasper
|
||||
|
||||
**What it does:** AI content creation platform
|
||||
|
||||
**Key features:**
|
||||
- Brand voice training
|
||||
- Multiple content templates
|
||||
- Team collaboration
|
||||
- Content repurposing
|
||||
|
||||
**Time saved:** 5-10 hours/week (for content creators)
|
||||
|
||||
**Price:** Starting at $49/month
|
||||
|
||||
### 7. Fireflies.ai
|
||||
|
||||
**What it does:** Meeting recording and intelligence
|
||||
|
||||
**Key features:**
|
||||
- Auto-join meetings
|
||||
- Searchable transcripts
|
||||
- Topic tracking
|
||||
- CRM integration
|
||||
|
||||
**Time saved:** 3-5 hours/week
|
||||
|
||||
**Price:** Free tier available, Pro at $18/month
|
||||
|
||||
### 8. Motion
|
||||
|
||||
**What it does:** AI project and task management
|
||||
|
||||
**Key features:**
|
||||
- Intelligent task scheduling
|
||||
- Project timeline optimization
|
||||
- Meeting scheduling
|
||||
- Deadline management
|
||||
|
||||
**Time saved:** 3-5 hours/week
|
||||
|
||||
**Price:** $19/month
|
||||
|
||||
### 9. Grammarly
|
||||
|
||||
**What it does:** AI writing assistant
|
||||
|
||||
**Key features:**
|
||||
- Grammar and spelling check
|
||||
- Tone detection
|
||||
- Clarity suggestions
|
||||
- Plagiarism detection
|
||||
|
||||
**Time saved:** 1-2 hours/week
|
||||
|
||||
**Price:** Free tier available, Premium at $12/month
|
||||
|
||||
### 10. Bardeen
|
||||
|
||||
**What it does:** AI workflow automation
|
||||
|
||||
**Key features:**
|
||||
- No-code automations
|
||||
- Cross-app integrations
|
||||
- AI-powered suggestions
|
||||
- Browser automation
|
||||
|
||||
**Time saved:** 2-5 hours/week
|
||||
|
||||
**Price:** Free tier available, Pro at $10/month
|
||||
|
||||
## Building Your AI Productivity Stack
|
||||
|
||||
### For Individual Contributors
|
||||
1. **Communication:** Superhuman + Otter.ai
|
||||
2. **Organization:** Notion AI + Reclaim.ai
|
||||
3. **Writing:** Grammarly
|
||||
|
||||
### For Managers
|
||||
1. **Meetings:** Otter.ai + Fireflies.ai
|
||||
2. **Scheduling:** Reclaim.ai + Motion
|
||||
3. **Team docs:** Notion AI
|
||||
|
||||
### For Content Creators
|
||||
1. **Writing:** Jasper + Grammarly
|
||||
2. **Research:** Mem
|
||||
3. **Automation:** Bardeen
|
||||
|
||||
## Implementation Tips
|
||||
|
||||
1. **Start with one tool** - Master it before adding more
|
||||
2. **Measure your time** - Track actual time saved
|
||||
3. **Integrate gradually** - Build habits around each tool
|
||||
4. **Review monthly** - Drop tools that don't deliver value
|
||||
5. **Train your team** - Shared tools need shared knowledge
|
||||
|
||||
## The ROI of AI Productivity Tools
|
||||
|
||||
| Tool Category | Avg. Cost/Month | Hours Saved/Week | Value Created |
|
||||
|---------------|-----------------|------------------|---------------|
|
||||
| Email | $30 | 5 | $325/week |
|
||||
| Meetings | $20 | 4 | $260/week |
|
||||
| Writing | $15 | 3 | $195/week |
|
||||
| Scheduling | $15 | 3 | $195/week |
|
||||
|
||||
*Based on $65/hour knowledge worker rate*
|
||||
|
||||
## Conclusion
|
||||
|
||||
AI productivity tools are no longer optional for competitive professionals. The right stack can save 10-20 hours per week - that's an extra month of productive time per year.
|
||||
|
||||
Explore our complete collection of AI productivity tools on Toolso.AI and start reclaiming your time today.
|
||||
@@ -0,0 +1,208 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-15",
|
||||
title: "2025年将改变你工作流程的10款AI生产力工具",
|
||||
description:
|
||||
"用这些改变游戏规则的AI工具提升你的生产力。从邮件管理到会议总结,发现AI如何每周为你节省数小时。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI:你的新生产力伙伴
|
||||
|
||||
普通知识工作者将60%的时间花在"关于工作的工作"上——邮件、会议、搜索信息。AI工具正在彻底改变这一状况,自动化处理琐碎任务,释放时间用于有意义的工作。
|
||||
|
||||
## 十大AI生产力工具
|
||||
|
||||
### 1. Notion AI
|
||||
|
||||
**功能:** 工作空间内的集成AI助手
|
||||
|
||||
**主要特性:**
|
||||
- 即时总结会议记录
|
||||
- 从文档生成行动项
|
||||
- 在上下文中写作和编辑
|
||||
- 对整个工作空间进行问答
|
||||
|
||||
**节省时间:** 每周3-5小时
|
||||
|
||||
**价格:** 包含在Notion计划中(免费用户$10/月附加)
|
||||
|
||||
### 2. Otter.ai
|
||||
|
||||
**功能:** AI会议转录和总结
|
||||
|
||||
**主要特性:**
|
||||
- 实时转录
|
||||
- 自动会议摘要
|
||||
- 行动项提取
|
||||
- 说话人识别
|
||||
|
||||
**节省时间:** 每周2-4小时
|
||||
|
||||
**价格:** 提供免费层级,Pro版$16.99/月
|
||||
|
||||
### 3. Superhuman
|
||||
|
||||
**功能:** AI驱动的邮件客户端
|
||||
|
||||
**主要特性:**
|
||||
- AI邮件写作辅助
|
||||
- 即时回复建议
|
||||
- 邮件优先级排序
|
||||
- 定时发送优化
|
||||
|
||||
**节省时间:** 每周4-6小时
|
||||
|
||||
**价格:** $30/月
|
||||
|
||||
### 4. Reclaim.ai
|
||||
|
||||
**功能:** 智能日历管理
|
||||
|
||||
**主要特性:**
|
||||
- 自动安排任务和习惯
|
||||
- 智能会议调度
|
||||
- 专注时间保护
|
||||
- 团队协调
|
||||
|
||||
**节省时间:** 每周2-3小时
|
||||
|
||||
**价格:** 提供免费层级,Pro版$8/月
|
||||
|
||||
### 5. Mem
|
||||
|
||||
**功能:** AI驱动的笔记和知识管理
|
||||
|
||||
**主要特性:**
|
||||
- 自动组织笔记
|
||||
- 跨所有内容的智能搜索
|
||||
- AI生成的关联
|
||||
- 会议集成
|
||||
|
||||
**节省时间:** 每周2-4小时
|
||||
|
||||
**价格:** 提供免费层级,Pro版$15/月
|
||||
|
||||
### 6. Jasper
|
||||
|
||||
**功能:** AI内容创作平台
|
||||
|
||||
**主要特性:**
|
||||
- 品牌语调训练
|
||||
- 多种内容模板
|
||||
- 团队协作
|
||||
- 内容再利用
|
||||
|
||||
**节省时间:** 每周5-10小时(内容创作者)
|
||||
|
||||
**价格:** 起价$49/月
|
||||
|
||||
### 7. Fireflies.ai
|
||||
|
||||
**功能:** 会议录制和智能分析
|
||||
|
||||
**主要特性:**
|
||||
- 自动加入会议
|
||||
- 可搜索的转录文本
|
||||
- 主题追踪
|
||||
- CRM集成
|
||||
|
||||
**节省时间:** 每周3-5小时
|
||||
|
||||
**价格:** 提供免费层级,Pro版$18/月
|
||||
|
||||
### 8. Motion
|
||||
|
||||
**功能:** AI项目和任务管理
|
||||
|
||||
**主要特性:**
|
||||
- 智能任务调度
|
||||
- 项目时间线优化
|
||||
- 会议安排
|
||||
- 截止日期管理
|
||||
|
||||
**节省时间:** 每周3-5小时
|
||||
|
||||
**价格:** $19/月
|
||||
|
||||
### 9. Grammarly
|
||||
|
||||
**功能:** AI写作助手
|
||||
|
||||
**主要特性:**
|
||||
- 语法和拼写检查
|
||||
- 语气检测
|
||||
- 清晰度建议
|
||||
- 抄袭检测
|
||||
|
||||
**节省时间:** 每周1-2小时
|
||||
|
||||
**价格:** 提供免费层级,Premium版$12/月
|
||||
|
||||
### 10. Bardeen
|
||||
|
||||
**功能:** AI工作流自动化
|
||||
|
||||
**主要特性:**
|
||||
- 无代码自动化
|
||||
- 跨应用集成
|
||||
- AI驱动的建议
|
||||
- 浏览器自动化
|
||||
|
||||
**节省时间:** 每周2-5小时
|
||||
|
||||
**价格:** 提供免费层级,Pro版$10/月
|
||||
|
||||
## 构建你的AI生产力工具栈
|
||||
|
||||
### 个人贡献者
|
||||
1. **沟通:** Superhuman + Otter.ai
|
||||
2. **组织:** Notion AI + Reclaim.ai
|
||||
3. **写作:** Grammarly
|
||||
|
||||
### 管理者
|
||||
1. **会议:** Otter.ai + Fireflies.ai
|
||||
2. **日程:** Reclaim.ai + Motion
|
||||
3. **团队文档:** Notion AI
|
||||
|
||||
### 内容创作者
|
||||
1. **写作:** Jasper + Grammarly
|
||||
2. **研究:** Mem
|
||||
3. **自动化:** Bardeen
|
||||
|
||||
## 实施建议
|
||||
|
||||
1. **从一个工具开始** - 掌握后再添加更多
|
||||
2. **衡量你的时间** - 跟踪实际节省的时间
|
||||
3. **逐步整合** - 围绕每个工具建立习惯
|
||||
4. **每月回顾** - 放弃不能带来价值的工具
|
||||
5. **培训团队** - 共享工具需要共享知识
|
||||
|
||||
## AI生产力工具的投资回报
|
||||
|
||||
| 工具类别 | 平均月费 | 每周节省时间 | 创造价值 |
|
||||
|----------|----------|--------------|----------|
|
||||
| 邮件 | $30 | 5小时 | $325/周 |
|
||||
| 会议 | $20 | 4小时 | $260/周 |
|
||||
| 写作 | $15 | 3小时 | $195/周 |
|
||||
| 日程 | $15 | 3小时 | $195/周 |
|
||||
|
||||
*基于$65/小时知识工作者费率*
|
||||
|
||||
## 结论
|
||||
|
||||
对于有竞争力的专业人士来说,AI生产力工具已不再是可选项。正确的工具组合每周可节省10-20小时——相当于每年多出一个月的生产时间。
|
||||
|
||||
在 Toolso.AI 探索我们完整的AI生产力工具集合,今天就开始夺回你的时间。
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-20",
|
||||
title: "AI Video Generation Tools: The Future of Content Creation",
|
||||
description:
|
||||
"Explore the revolutionary world of AI video generation. From text-to-video to AI avatars, discover tools that are transforming how we create video content.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1492619375914-88005aa9e8fb?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The AI Video Revolution
|
||||
|
||||
Video content has always been resource-intensive to produce. AI is changing that dramatically, making professional-quality video creation accessible to everyone.
|
||||
|
||||
## Categories of AI Video Tools
|
||||
|
||||
### Text-to-Video Generation
|
||||
|
||||
These tools create videos from text descriptions alone.
|
||||
|
||||
**Runway Gen-2**
|
||||
- Industry-leading quality
|
||||
- Motion brush for precise control
|
||||
- Up to 4 seconds per generation
|
||||
- **Price:** Starting at $15/month
|
||||
|
||||
**Pika Labs**
|
||||
- Excellent motion quality
|
||||
- Discord and web interface
|
||||
- Free tier available
|
||||
- **Price:** Free tier + paid plans
|
||||
|
||||
**Sora (OpenAI)**
|
||||
- Groundbreaking quality (limited access)
|
||||
- Up to 60 seconds
|
||||
- Realistic physics simulation
|
||||
- **Price:** Not yet publicly available
|
||||
|
||||
### AI Avatar Videos
|
||||
|
||||
Create videos with AI-generated presenters.
|
||||
|
||||
**HeyGen**
|
||||
- 100+ AI avatars
|
||||
- Voice cloning
|
||||
- Multi-language support
|
||||
- **Best for:** Corporate training, marketing
|
||||
|
||||
**Synthesia**
|
||||
- Enterprise-focused
|
||||
- Custom avatar creation
|
||||
- SOC 2 compliant
|
||||
- **Best for:** Large organizations
|
||||
|
||||
**D-ID**
|
||||
- Photo-to-video animation
|
||||
- API available
|
||||
- Quick turnaround
|
||||
- **Best for:** Personalized videos
|
||||
|
||||
### Video Editing & Enhancement
|
||||
|
||||
AI-powered tools for improving existing footage.
|
||||
|
||||
**Runway**
|
||||
- Remove backgrounds
|
||||
- Object removal
|
||||
- Color grading
|
||||
- **Best for:** Professional editors
|
||||
|
||||
**Descript**
|
||||
- Text-based video editing
|
||||
- Filler word removal
|
||||
- Overdub voice cloning
|
||||
- **Best for:** Podcasters and educators
|
||||
|
||||
**CapCut**
|
||||
- Auto-captions
|
||||
- Background removal
|
||||
- Free to use
|
||||
- **Best for:** Social media creators
|
||||
|
||||
### AI Video Upscaling
|
||||
|
||||
Enhance video quality with AI.
|
||||
|
||||
**Topaz Video AI**
|
||||
- Up to 8K upscaling
|
||||
- Frame rate conversion
|
||||
- Noise reduction
|
||||
- **Best for:** Restoring old footage
|
||||
|
||||
**Neural Love**
|
||||
- Online upscaling
|
||||
- Multiple enhancement options
|
||||
- Batch processing
|
||||
- **Best for:** Quick online upscaling
|
||||
|
||||
## Use Cases and Recommendations
|
||||
|
||||
### Marketing & Advertising
|
||||
**Recommended:** HeyGen + Runway
|
||||
- Create spokesperson videos without actors
|
||||
- Generate B-roll footage from descriptions
|
||||
|
||||
### Social Media Content
|
||||
**Recommended:** CapCut + Pika Labs
|
||||
- Quick editing with auto-captions
|
||||
- Eye-catching AI-generated clips
|
||||
|
||||
### Corporate Training
|
||||
**Recommended:** Synthesia + Descript
|
||||
- Consistent AI presenters
|
||||
- Easy script updates
|
||||
|
||||
### Film & Entertainment
|
||||
**Recommended:** Runway + Topaz
|
||||
- Professional-grade generation
|
||||
- High-quality upscaling
|
||||
|
||||
## Current Limitations
|
||||
|
||||
Be aware of these limitations:
|
||||
|
||||
1. **Length constraints** - Most tools limited to seconds, not minutes
|
||||
2. **Consistency issues** - Characters may change between shots
|
||||
3. **Text rendering** - Text in videos often appears garbled
|
||||
4. **Hands and faces** - Still challenging for AI
|
||||
5. **Computing costs** - High-quality generation is expensive
|
||||
|
||||
## The Future of AI Video
|
||||
|
||||
What's coming:
|
||||
|
||||
- **Longer generations** - Minutes instead of seconds
|
||||
- **Better consistency** - Maintain characters across scenes
|
||||
- **Real-time generation** - Interactive AI video
|
||||
- **Improved audio sync** - Natural lip movements
|
||||
- **Accessible pricing** - More affordable for individuals
|
||||
|
||||
## Getting Started Tips
|
||||
|
||||
1. **Start with free tiers** - Test before committing
|
||||
2. **Learn prompting** - Better prompts = better results
|
||||
3. **Combine tools** - Use multiple tools for best results
|
||||
4. **Plan your workflow** - AI is a tool, not a replacement
|
||||
5. **Stay ethical** - Be transparent about AI use
|
||||
|
||||
## Conclusion
|
||||
|
||||
AI video generation is still in its early stages but evolving rapidly. The tools available today can already save significant time and resources for content creators. As the technology matures, we'll see even more impressive capabilities.
|
||||
|
||||
Explore our curated collection of AI video tools on Toolso.AI to find the perfect solution for your video creation needs.
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-20",
|
||||
title: "AI视频生成工具:内容创作的未来",
|
||||
description:
|
||||
"探索AI视频生成的革命性世界。从文字转视频到AI虚拟人,发现正在改变我们创作视频内容方式的工具。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1492619375914-88005aa9e8fb?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI视频革命
|
||||
|
||||
视频内容的制作一直是资源密集型的。AI正在彻底改变这一点,让专业品质的视频创作变得人人可及。
|
||||
|
||||
## AI视频工具分类
|
||||
|
||||
### 文字转视频生成
|
||||
|
||||
这些工具仅从文字描述就能创建视频。
|
||||
|
||||
**Runway Gen-2**
|
||||
- 行业领先的质量
|
||||
- 运动笔刷精确控制
|
||||
- 每次生成最多4秒
|
||||
- **价格:** 起价$15/月
|
||||
|
||||
**Pika Labs**
|
||||
- 出色的运动质量
|
||||
- Discord和网页界面
|
||||
- 提供免费层级
|
||||
- **价格:** 免费层级 + 付费计划
|
||||
|
||||
**Sora (OpenAI)**
|
||||
- 突破性的质量(限制访问)
|
||||
- 最长60秒
|
||||
- 逼真的物理模拟
|
||||
- **价格:** 尚未公开发布
|
||||
|
||||
### AI虚拟人视频
|
||||
|
||||
使用AI生成的主持人创建视频。
|
||||
|
||||
**HeyGen**
|
||||
- 100+AI虚拟人形象
|
||||
- 语音克隆
|
||||
- 多语言支持
|
||||
- **最适合:** 企业培训、营销
|
||||
|
||||
**Synthesia**
|
||||
- 专注企业
|
||||
- 自定义虚拟人创建
|
||||
- SOC 2合规
|
||||
- **最适合:** 大型组织
|
||||
|
||||
**D-ID**
|
||||
- 照片转视频动画
|
||||
- 提供API
|
||||
- 快速周转
|
||||
- **最适合:** 个性化视频
|
||||
|
||||
### 视频编辑与增强
|
||||
|
||||
用于改进现有素材的AI驱动工具。
|
||||
|
||||
**Runway**
|
||||
- 移除背景
|
||||
- 物体移除
|
||||
- 色彩调整
|
||||
- **最适合:** 专业剪辑师
|
||||
|
||||
**Descript**
|
||||
- 基于文本的视频编辑
|
||||
- 移除填充词
|
||||
- Overdub语音克隆
|
||||
- **最适合:** 播客主和教育者
|
||||
|
||||
**CapCut**
|
||||
- 自动字幕
|
||||
- 背景移除
|
||||
- 免费使用
|
||||
- **最适合:** 社交媒体创作者
|
||||
|
||||
### AI视频升级
|
||||
|
||||
用AI增强视频质量。
|
||||
|
||||
**Topaz Video AI**
|
||||
- 最高8K升级
|
||||
- 帧率转换
|
||||
- 降噪
|
||||
- **最适合:** 修复老旧素材
|
||||
|
||||
**Neural Love**
|
||||
- 在线升级
|
||||
- 多种增强选项
|
||||
- 批量处理
|
||||
- **最适合:** 快速在线升级
|
||||
|
||||
## 使用场景和推荐
|
||||
|
||||
### 营销与广告
|
||||
**推荐:** HeyGen + Runway
|
||||
- 无需演员即可创建代言人视频
|
||||
- 从描述生成B-roll素材
|
||||
|
||||
### 社交媒体内容
|
||||
**推荐:** CapCut + Pika Labs
|
||||
- 带自动字幕的快速编辑
|
||||
- 引人注目的AI生成短片
|
||||
|
||||
### 企业培训
|
||||
**推荐:** Synthesia + Descript
|
||||
- 一致的AI主持人
|
||||
- 轻松更新脚本
|
||||
|
||||
### 影视娱乐
|
||||
**推荐:** Runway + Topaz
|
||||
- 专业级生成
|
||||
- 高质量升级
|
||||
|
||||
## 当前局限性
|
||||
|
||||
请注意以下局限:
|
||||
|
||||
1. **时长限制** - 大多数工具限于几秒,而非几分钟
|
||||
2. **一致性问题** - 角色可能在不同镜头间变化
|
||||
3. **文字渲染** - 视频中的文字经常显示乱码
|
||||
4. **手部和面部** - 对AI来说仍具挑战性
|
||||
5. **计算成本** - 高质量生成很昂贵
|
||||
|
||||
## AI视频的未来
|
||||
|
||||
即将到来的发展:
|
||||
|
||||
- **更长的生成** - 分钟级而非秒级
|
||||
- **更好的一致性** - 跨场景保持角色
|
||||
- **实时生成** - 交互式AI视频
|
||||
- **改进的音频同步** - 自然的唇部动作
|
||||
- **亲民的定价** - 个人用户更能负担
|
||||
|
||||
## 入门建议
|
||||
|
||||
1. **从免费层级开始** - 承诺前先测试
|
||||
2. **学习提示词技巧** - 更好的提示词 = 更好的结果
|
||||
3. **组合工具** - 使用多个工具获得最佳效果
|
||||
4. **规划工作流程** - AI是工具,不是替代品
|
||||
5. **保持道德** - 对AI使用保持透明
|
||||
|
||||
## 结论
|
||||
|
||||
AI视频生成仍处于早期阶段,但发展迅速。目前可用的工具已经能为内容创作者节省大量时间和资源。随着技术成熟,我们将看到更令人印象深刻的能力。
|
||||
|
||||
在 Toolso.AI 探索我们精选的AI视频工具集合,找到满足您视频创作需求的完美解决方案。
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-03",
|
||||
title: "Best AI Tools in 2025: A Complete Guide",
|
||||
description:
|
||||
"Discover the most powerful AI tools of 2025. From ChatGPT to Midjourney, we cover the best AI applications for productivity, creativity, and business.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The AI Revolution in 2025
|
||||
|
||||
Artificial Intelligence has transformed the way we work, create, and communicate. In 2025, AI tools have become more accessible, powerful, and diverse than ever before. Whether you're a developer, designer, writer, or business professional, there's an AI tool designed to enhance your workflow.
|
||||
|
||||
## Top AI Categories
|
||||
|
||||
### 1. AI Chatbots & Assistants
|
||||
|
||||
The conversational AI space has seen remarkable growth:
|
||||
|
||||
- **ChatGPT** - OpenAI's flagship product continues to lead with GPT-4 and beyond
|
||||
- **Claude** - Anthropic's AI assistant known for nuanced understanding
|
||||
- **Gemini** - Google's multimodal AI powerhouse
|
||||
|
||||
### 2. AI Image Generation
|
||||
|
||||
Visual AI tools have revolutionized creative work:
|
||||
|
||||
- **Midjourney** - Industry leader for artistic image generation
|
||||
- **DALL-E 3** - OpenAI's image generator with exceptional prompt understanding
|
||||
- **Stable Diffusion** - Open-source flexibility for developers
|
||||
|
||||
### 3. AI Writing Tools
|
||||
|
||||
Content creation has been transformed:
|
||||
|
||||
- **Jasper** - Enterprise-focused AI writing platform
|
||||
- **Copy.ai** - Marketing-focused content generation
|
||||
- **Notion AI** - Integrated writing assistance in your workspace
|
||||
|
||||
### 4. AI Video Tools
|
||||
|
||||
Video creation is now accessible to everyone:
|
||||
|
||||
- **Runway** - Professional video generation and editing
|
||||
- **Pika** - Text-to-video with stunning results
|
||||
- **HeyGen** - AI avatar video creation
|
||||
|
||||
## How to Choose the Right AI Tool
|
||||
|
||||
When selecting an AI tool, consider:
|
||||
|
||||
1. **Your specific use case** - What problem are you solving?
|
||||
2. **Integration needs** - Does it work with your existing tools?
|
||||
3. **Pricing** - Free tier vs. paid features
|
||||
4. **Learning curve** - How quickly can you get started?
|
||||
|
||||
## Conclusion
|
||||
|
||||
The AI landscape in 2025 offers unprecedented opportunities for productivity and creativity. Explore our curated collection of AI tools on Toolso.AI to find the perfect solutions for your needs.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-03",
|
||||
title: "2025年最佳AI工具完全指南",
|
||||
description:
|
||||
"探索2025年最强大的AI工具。从ChatGPT到Midjourney,我们为您介绍生产力、创意和商业领域最佳的AI应用。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## 2025年的AI革命
|
||||
|
||||
人工智能已经彻底改变了我们工作、创作和沟通的方式。在2025年,AI工具变得比以往任何时候都更加易用、强大和多样化。无论您是开发者、设计师、作家还是商业专业人士,都有专门为增强您工作流程而设计的AI工具。
|
||||
|
||||
## 顶级AI工具分类
|
||||
|
||||
### 1. AI聊天机器人与助手
|
||||
|
||||
对话式AI领域取得了显著增长:
|
||||
|
||||
- **ChatGPT** - OpenAI的旗舰产品,凭借GPT-4及更高版本继续领先
|
||||
- **Claude** - Anthropic的AI助手,以细腻的理解能力著称
|
||||
- **Gemini** - Google的多模态AI强者
|
||||
|
||||
### 2. AI图像生成
|
||||
|
||||
视觉AI工具彻底改变了创意工作:
|
||||
|
||||
- **Midjourney** - 艺术图像生成的行业领导者
|
||||
- **DALL-E 3** - OpenAI的图像生成器,具有卓越的提示词理解能力
|
||||
- **Stable Diffusion** - 为开发者提供开源灵活性
|
||||
|
||||
### 3. AI写作工具
|
||||
|
||||
内容创作已被彻底变革:
|
||||
|
||||
- **Jasper** - 专注企业的AI写作平台
|
||||
- **Copy.ai** - 专注营销的内容生成
|
||||
- **Notion AI** - 工作空间中的集成写作助手
|
||||
|
||||
### 4. AI视频工具
|
||||
|
||||
视频创作现在人人可及:
|
||||
|
||||
- **Runway** - 专业视频生成和编辑
|
||||
- **Pika** - 效果惊艳的文字转视频
|
||||
- **HeyGen** - AI虚拟人视频创作
|
||||
|
||||
## 如何选择合适的AI工具
|
||||
|
||||
选择AI工具时,请考虑:
|
||||
|
||||
1. **您的具体用例** - 您要解决什么问题?
|
||||
2. **集成需求** - 它能与您现有的工具配合使用吗?
|
||||
3. **定价** - 免费版与付费功能的对比
|
||||
4. **学习曲线** - 您能多快上手?
|
||||
|
||||
## 结论
|
||||
|
||||
2025年的AI领域为生产力和创意提供了前所未有的机遇。浏览 Toolso.AI 精选的AI工具集合,找到最适合您需求的解决方案。
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-25",
|
||||
title: "Best Free AI Tools in 2025: No Credit Card Required",
|
||||
description:
|
||||
"Discover powerful AI tools that won't cost you a penny. From image generation to writing assistants, explore the best free AI resources available today.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## Free AI Tools That Actually Deliver
|
||||
|
||||
You don't need to spend a fortune to harness the power of AI. Many cutting-edge tools offer generous free tiers that can handle most personal and small business needs.
|
||||
|
||||
## Free AI Chatbots
|
||||
|
||||
### ChatGPT Free
|
||||
- Access to GPT-3.5
|
||||
- Unlimited conversations
|
||||
- Basic coding assistance
|
||||
- **Limitation:** No GPT-4 access, slower during peak times
|
||||
|
||||
### Claude.ai Free
|
||||
- Access to Claude 3 Sonnet
|
||||
- Generous daily message limit
|
||||
- Long document analysis
|
||||
- **Limitation:** Lower priority during high demand
|
||||
|
||||
### Google Gemini Free
|
||||
- Access to Gemini Pro
|
||||
- Real-time web access
|
||||
- Google integration
|
||||
- **Limitation:** Limited advanced features
|
||||
|
||||
### Microsoft Copilot Free
|
||||
- GPT-4 powered
|
||||
- Image generation included
|
||||
- No sign-up required
|
||||
- **Limitation:** Daily conversation limits
|
||||
|
||||
## Free AI Image Generators
|
||||
|
||||
### Bing Image Creator
|
||||
- DALL-E 3 powered
|
||||
- High-quality outputs
|
||||
- 15 free boosts daily
|
||||
- **Best for:** Quick, quality image generation
|
||||
|
||||
### Leonardo.ai Free
|
||||
- 150 daily tokens
|
||||
- Multiple model options
|
||||
- Community models access
|
||||
- **Best for:** Artistic and game asset creation
|
||||
|
||||
### Playground AI
|
||||
- 500 free images daily
|
||||
- Multiple AI models
|
||||
- Image editing tools
|
||||
- **Best for:** Volume image generation
|
||||
|
||||
### Canva AI (Free Tier)
|
||||
- Limited AI image generation
|
||||
- Design template integration
|
||||
- Easy editing tools
|
||||
- **Best for:** Marketing materials
|
||||
|
||||
## Free AI Writing Tools
|
||||
|
||||
### Notion AI (Limited)
|
||||
- Free trial available
|
||||
- Integrated with Notion workspace
|
||||
- Summarization and writing help
|
||||
- **Best for:** Note-taking and organization
|
||||
|
||||
### Grammarly Free
|
||||
- Basic grammar checking
|
||||
- Tone detection
|
||||
- Browser extension
|
||||
- **Best for:** Error-free writing
|
||||
|
||||
### QuillBot Free
|
||||
- Paraphrasing tool
|
||||
- Grammar checker
|
||||
- Summarizer (limited)
|
||||
- **Best for:** Rewriting and paraphrasing
|
||||
|
||||
### Copy.ai Free
|
||||
- 2,000 words per month
|
||||
- 90+ copywriting tools
|
||||
- Multiple languages
|
||||
- **Best for:** Marketing copy snippets
|
||||
|
||||
## Free AI Coding Assistants
|
||||
|
||||
### GitHub Copilot (Students/Open Source)
|
||||
- Free for students and maintainers
|
||||
- VS Code integration
|
||||
- Code completion
|
||||
- **Best for:** Students and open source contributors
|
||||
|
||||
### Codeium Free
|
||||
- Unlimited code completion
|
||||
- Multiple IDE support
|
||||
- Chat assistance
|
||||
- **Best for:** Professional developers on a budget
|
||||
|
||||
### Amazon CodeWhisperer Free
|
||||
- Individual tier is free
|
||||
- AWS integration
|
||||
- Security scanning
|
||||
- **Best for:** AWS developers
|
||||
|
||||
### Replit AI (Free Tier)
|
||||
- Basic code assistance
|
||||
- Browser-based IDE
|
||||
- Deployment included
|
||||
- **Best for:** Learning and prototyping
|
||||
|
||||
## Free AI Audio & Video Tools
|
||||
|
||||
### ElevenLabs Free
|
||||
- 10,000 characters/month
|
||||
- High-quality voice synthesis
|
||||
- Multiple voices
|
||||
- **Best for:** Short voiceover projects
|
||||
|
||||
### Kapwing Free
|
||||
- Basic video editing
|
||||
- AI-powered features
|
||||
- Watermark on exports
|
||||
- **Best for:** Social media clips
|
||||
|
||||
### Descript Free
|
||||
- 1 hour of transcription
|
||||
- Basic editing
|
||||
- Screen recording
|
||||
- **Best for:** Podcast editing
|
||||
|
||||
## Tips for Maximizing Free AI Tools
|
||||
|
||||
1. **Combine multiple free tools** - Use different tools for different tasks
|
||||
2. **Create multiple accounts** - Where terms allow, for different projects
|
||||
3. **Time your usage** - Use free tiers during off-peak hours
|
||||
4. **Export and save** - Don't lose your generated content
|
||||
5. **Stay updated** - Free tiers often change
|
||||
|
||||
## When to Upgrade
|
||||
|
||||
Consider paid plans when:
|
||||
- You hit free limits regularly
|
||||
- Quality becomes critical
|
||||
- Time savings justify the cost
|
||||
- You need advanced features
|
||||
|
||||
## Conclusion
|
||||
|
||||
The free AI tool landscape is remarkably capable. For many users, free tiers provide everything needed to boost productivity and creativity. Start with free options and upgrade only when necessary.
|
||||
|
||||
Browse our complete collection of free AI tools on Toolso.AI - we've tagged all tools with free tiers for easy discovery.
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2024-12-25",
|
||||
title: "2025年最佳免费AI工具:无需信用卡",
|
||||
description:
|
||||
"发现不花一分钱就能使用的强大AI工具。从图像生成到写作助手,探索当今最佳的免费AI资源。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## 真正好用的免费AI工具
|
||||
|
||||
利用AI的力量不需要花费大量金钱。许多尖端工具提供慷慨的免费层级,可以满足大多数个人和小型企业的需求。
|
||||
|
||||
## 免费AI聊天机器人
|
||||
|
||||
### ChatGPT 免费版
|
||||
- 访问GPT-3.5
|
||||
- 无限对话
|
||||
- 基础编程辅助
|
||||
- **限制:** 无法访问GPT-4,高峰期较慢
|
||||
|
||||
### Claude.ai 免费版
|
||||
- 访问Claude 3 Sonnet
|
||||
- 慷慨的每日消息限制
|
||||
- 长文档分析
|
||||
- **限制:** 高需求期间优先级较低
|
||||
|
||||
### Google Gemini 免费版
|
||||
- 访问Gemini Pro
|
||||
- 实时网络访问
|
||||
- Google集成
|
||||
- **限制:** 高级功能受限
|
||||
|
||||
### Microsoft Copilot 免费版
|
||||
- GPT-4驱动
|
||||
- 包含图像生成
|
||||
- 无需注册
|
||||
- **限制:** 每日对话限制
|
||||
|
||||
## 免费AI图像生成器
|
||||
|
||||
### Bing Image Creator
|
||||
- DALL-E 3驱动
|
||||
- 高质量输出
|
||||
- 每日15次免费加速
|
||||
- **最适合:** 快速、高质量图像生成
|
||||
|
||||
### Leonardo.ai 免费版
|
||||
- 每日150个代币
|
||||
- 多种模型选项
|
||||
- 社区模型访问
|
||||
- **最适合:** 艺术和游戏资产创作
|
||||
|
||||
### Playground AI
|
||||
- 每日500张免费图片
|
||||
- 多种AI模型
|
||||
- 图像编辑工具
|
||||
- **最适合:** 批量图像生成
|
||||
|
||||
### Canva AI(免费层级)
|
||||
- 有限的AI图像生成
|
||||
- 设计模板集成
|
||||
- 简单编辑工具
|
||||
- **最适合:** 营销材料
|
||||
|
||||
## 免费AI写作工具
|
||||
|
||||
### Notion AI(有限)
|
||||
- 提供免费试用
|
||||
- 与Notion工作空间集成
|
||||
- 摘要和写作帮助
|
||||
- **最适合:** 笔记和组织
|
||||
|
||||
### Grammarly 免费版
|
||||
- 基础语法检查
|
||||
- 语气检测
|
||||
- 浏览器扩展
|
||||
- **最适合:** 无错误写作
|
||||
|
||||
### QuillBot 免费版
|
||||
- 改写工具
|
||||
- 语法检查器
|
||||
- 摘要器(有限)
|
||||
- **最适合:** 改写和释义
|
||||
|
||||
### Copy.ai 免费版
|
||||
- 每月2,000字
|
||||
- 90+文案工具
|
||||
- 多语言支持
|
||||
- **最适合:** 营销文案片段
|
||||
|
||||
## 免费AI编程助手
|
||||
|
||||
### GitHub Copilot(学生/开源)
|
||||
- 对学生和维护者免费
|
||||
- VS Code集成
|
||||
- 代码补全
|
||||
- **最适合:** 学生和开源贡献者
|
||||
|
||||
### Codeium 免费版
|
||||
- 无限代码补全
|
||||
- 多IDE支持
|
||||
- 聊天辅助
|
||||
- **最适合:** 预算有限的专业开发者
|
||||
|
||||
### Amazon CodeWhisperer 免费版
|
||||
- 个人层级免费
|
||||
- AWS集成
|
||||
- 安全扫描
|
||||
- **最适合:** AWS开发者
|
||||
|
||||
### Replit AI(免费层级)
|
||||
- 基础代码辅助
|
||||
- 基于浏览器的IDE
|
||||
- 包含部署
|
||||
- **最适合:** 学习和原型开发
|
||||
|
||||
## 免费AI音视频工具
|
||||
|
||||
### ElevenLabs 免费版
|
||||
- 每月10,000字符
|
||||
- 高质量语音合成
|
||||
- 多种声音
|
||||
- **最适合:** 短配音项目
|
||||
|
||||
### Kapwing 免费版
|
||||
- 基础视频编辑
|
||||
- AI驱动功能
|
||||
- 导出带水印
|
||||
- **最适合:** 社交媒体短片
|
||||
|
||||
### Descript 免费版
|
||||
- 1小时转录
|
||||
- 基础编辑
|
||||
- 屏幕录制
|
||||
- **最适合:** 播客编辑
|
||||
|
||||
## 最大化利用免费AI工具的技巧
|
||||
|
||||
1. **组合多个免费工具** - 不同任务使用不同工具
|
||||
2. **创建多个账户** - 在条款允许的情况下,用于不同项目
|
||||
3. **合理安排使用时间** - 在非高峰时段使用免费层级
|
||||
4. **导出并保存** - 不要丢失生成的内容
|
||||
5. **保持更新** - 免费层级经常变化
|
||||
|
||||
## 何时升级
|
||||
|
||||
在以下情况考虑付费计划:
|
||||
- 经常达到免费限制
|
||||
- 质量变得至关重要
|
||||
- 节省的时间值得付费
|
||||
- 需要高级功能
|
||||
|
||||
## 结论
|
||||
|
||||
免费AI工具的能力非常出色。对于许多用户来说,免费层级提供了提升生产力和创造力所需的一切。从免费选项开始,只在必要时升级。
|
||||
|
||||
在 Toolso.AI 浏览我们完整的免费AI工具集合 - 我们已为所有带免费层级的工具添加标签,方便发现。
|
||||
@@ -0,0 +1,130 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-01",
|
||||
title: "How to Choose the Right AI Writing Tool for Your Needs",
|
||||
description:
|
||||
"Navigate the crowded AI writing tool market with our comprehensive guide. From blogging to marketing copy, find the perfect AI assistant for your content creation.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## The AI Writing Tool Landscape
|
||||
|
||||
With dozens of AI writing tools flooding the market, choosing the right one can feel overwhelming. This guide will help you cut through the noise and find your perfect match.
|
||||
|
||||
## Understanding Your Needs
|
||||
|
||||
Before diving into tools, ask yourself:
|
||||
|
||||
1. **What type of content do you create?**
|
||||
- Blog posts and articles
|
||||
- Marketing copy
|
||||
- Technical documentation
|
||||
- Social media content
|
||||
- Email campaigns
|
||||
|
||||
2. **What's your workflow like?**
|
||||
- Do you need standalone tools or integrations?
|
||||
- How important is collaboration?
|
||||
- What's your budget?
|
||||
|
||||
## Top AI Writing Tools by Use Case
|
||||
|
||||
### For Long-Form Content
|
||||
|
||||
**ChatGPT / Claude**
|
||||
- Best for brainstorming and drafting
|
||||
- Excellent for research assistance
|
||||
- Flexible prompting for any content type
|
||||
|
||||
**Jasper**
|
||||
- Purpose-built for marketing content
|
||||
- Templates for various content types
|
||||
- Brand voice consistency features
|
||||
|
||||
### For Marketing Copy
|
||||
|
||||
**Copy.ai**
|
||||
- Quick generation of marketing variants
|
||||
- A/B testing suggestions
|
||||
- Social media post generation
|
||||
|
||||
**Writesonic**
|
||||
- Landing page copy
|
||||
- Product descriptions
|
||||
- Ad copy variations
|
||||
|
||||
### For SEO Content
|
||||
|
||||
**Surfer SEO + AI**
|
||||
- SEO-optimized content suggestions
|
||||
- Keyword integration guidance
|
||||
- Content score tracking
|
||||
|
||||
**Clearscope**
|
||||
- Comprehensive content optimization
|
||||
- Competitor analysis
|
||||
- Readability scoring
|
||||
|
||||
### For Technical Writing
|
||||
|
||||
**GitHub Copilot**
|
||||
- Code documentation
|
||||
- Technical explanations
|
||||
- API documentation
|
||||
|
||||
**Notion AI**
|
||||
- Integrated workspace writing
|
||||
- Meeting notes summarization
|
||||
- Technical specifications
|
||||
|
||||
## Key Features to Consider
|
||||
|
||||
### 1. Output Quality
|
||||
Not all AI writing is equal. Test each tool with your specific use cases.
|
||||
|
||||
### 2. Customization
|
||||
Can you train the tool on your brand voice? Does it learn from your feedback?
|
||||
|
||||
### 3. Integration
|
||||
Does it work with your existing tools? Consider:
|
||||
- Google Docs / Microsoft Word
|
||||
- Content management systems
|
||||
- SEO tools
|
||||
|
||||
### 4. Pricing Model
|
||||
- Per-word pricing
|
||||
- Monthly subscriptions
|
||||
- Usage-based credits
|
||||
|
||||
## Our Recommendations
|
||||
|
||||
| Use Case | Recommended Tool | Why |
|
||||
|----------|------------------|-----|
|
||||
| General Writing | ChatGPT Plus | Versatility and quality |
|
||||
| Marketing | Jasper | Purpose-built features |
|
||||
| SEO Content | Surfer SEO | Optimization focus |
|
||||
| Technical | GitHub Copilot | Code-aware |
|
||||
| Quick Copy | Copy.ai | Speed and templates |
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Start with free trials** - Most tools offer them
|
||||
2. **Test with real projects** - Use your actual content needs
|
||||
3. **Compare outputs** - Try the same prompt across tools
|
||||
4. **Consider the learning curve** - Factor in time to proficiency
|
||||
|
||||
## Conclusion
|
||||
|
||||
The best AI writing tool is the one that fits your specific workflow and content needs. Don't just follow the hype - test thoroughly and choose based on your requirements. Browse our complete collection of AI writing tools on Toolso.AI.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { BlogLayout } from "@/components/blog-layout";
|
||||
|
||||
export const blog = {
|
||||
author: { name: "Toolso.AI", src: "/avatar.jpeg" },
|
||||
date: "2025-01-01",
|
||||
title: "如何选择适合您需求的AI写作工具",
|
||||
description:
|
||||
"通过我们的全面指南,帮您在众多AI写作工具中做出选择。从博客写作到营销文案,找到最适合您内容创作的AI助手。",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&auto=format&fit=crop&q=60",
|
||||
};
|
||||
export const metadata = {
|
||||
title: blog.title,
|
||||
description: blog.description,
|
||||
openGraph: {
|
||||
images: [blog.image],
|
||||
},
|
||||
};
|
||||
|
||||
export default (props) => <BlogLayout blog={blog} {...props} />;
|
||||
|
||||
## AI写作工具市场概况
|
||||
|
||||
随着数十款AI写作工具涌入市场,选择合适的工具可能让人感到不知所措。本指南将帮助您拨开迷雾,找到最适合您的工具。
|
||||
|
||||
## 了解您的需求
|
||||
|
||||
在深入了解工具之前,先问问自己:
|
||||
|
||||
1. **您创作什么类型的内容?**
|
||||
- 博客文章
|
||||
- 营销文案
|
||||
- 技术文档
|
||||
- 社交媒体内容
|
||||
- 邮件营销
|
||||
|
||||
2. **您的工作流程是怎样的?**
|
||||
- 您需要独立工具还是集成方案?
|
||||
- 协作有多重要?
|
||||
- 您的预算是多少?
|
||||
|
||||
## 按使用场景推荐的顶级AI写作工具
|
||||
|
||||
### 长篇内容写作
|
||||
|
||||
**ChatGPT / Claude**
|
||||
- 最适合头脑风暴和起草
|
||||
- 出色的研究辅助能力
|
||||
- 灵活的提示词适用于任何内容类型
|
||||
|
||||
**Jasper**
|
||||
- 专为营销内容打造
|
||||
- 各种内容类型的模板
|
||||
- 品牌语调一致性功能
|
||||
|
||||
### 营销文案
|
||||
|
||||
**Copy.ai**
|
||||
- 快速生成营销变体
|
||||
- A/B测试建议
|
||||
- 社交媒体帖子生成
|
||||
|
||||
**Writesonic**
|
||||
- 落地页文案
|
||||
- 产品描述
|
||||
- 广告文案变体
|
||||
|
||||
### SEO内容
|
||||
|
||||
**Surfer SEO + AI**
|
||||
- SEO优化内容建议
|
||||
- 关键词整合指导
|
||||
- 内容评分追踪
|
||||
|
||||
**Clearscope**
|
||||
- 全面的内容优化
|
||||
- 竞争对手分析
|
||||
- 可读性评分
|
||||
|
||||
### 技术写作
|
||||
|
||||
**GitHub Copilot**
|
||||
- 代码文档
|
||||
- 技术说明
|
||||
- API文档
|
||||
|
||||
**Notion AI**
|
||||
- 集成工作空间写作
|
||||
- 会议记录总结
|
||||
- 技术规格说明
|
||||
|
||||
## 需要考虑的关键特性
|
||||
|
||||
### 1. 输出质量
|
||||
并非所有AI写作质量相同。用您的具体用例测试每个工具。
|
||||
|
||||
### 2. 定制化
|
||||
您能用品牌语调训练工具吗?它会从您的反馈中学习吗?
|
||||
|
||||
### 3. 集成性
|
||||
它能与您现有的工具配合使用吗?考虑:
|
||||
- Google Docs / Microsoft Word
|
||||
- 内容管理系统
|
||||
- SEO工具
|
||||
|
||||
### 4. 定价模式
|
||||
- 按字数定价
|
||||
- 月度订阅
|
||||
- 基于使用量的积分
|
||||
|
||||
## 我们的推荐
|
||||
|
||||
| 使用场景 | 推荐工具 | 原因 |
|
||||
|----------|----------|------|
|
||||
| 通用写作 | ChatGPT Plus | 多功能性和质量 |
|
||||
| 营销 | Jasper | 专门构建的功能 |
|
||||
| SEO内容 | Surfer SEO | 专注优化 |
|
||||
| 技术写作 | GitHub Copilot | 代码感知 |
|
||||
| 快速文案 | Copy.ai | 速度和模板 |
|
||||
|
||||
## 入门建议
|
||||
|
||||
1. **从免费试用开始** - 大多数工具都提供试用
|
||||
2. **用真实项目测试** - 使用您实际的内容需求
|
||||
3. **比较输出结果** - 在不同工具中尝试相同的提示词
|
||||
4. **考虑学习曲线** - 将熟练掌握的时间计算在内
|
||||
|
||||
## 结论
|
||||
|
||||
最好的AI写作工具是适合您特定工作流程和内容需求的工具。不要只跟风 - 彻底测试并根据您的要求做出选择。在 Toolso.AI 浏览我们完整的AI写作工具集合。
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type Metadata } from "next";
|
||||
import { getAllBlogs } from "@/lib/blog";
|
||||
import { Background } from "@/components/background";
|
||||
import { Container } from "@/components/container";
|
||||
import { Heading } from "@/components/heading";
|
||||
import { Subheading } from "@/components/subheading";
|
||||
import { BlogCard } from "@/components/blog-card";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { type Locale } from '@/i18n.config';
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'blog' });
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: '/blog',
|
||||
title: t('title'),
|
||||
description: t('subtitle'),
|
||||
});
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: Locale;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ArticlesIndex({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'blog' });
|
||||
let blogs = await getAllBlogs(locale);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden py-20 md:py-0">
|
||||
<Background />
|
||||
<Container className="flex flex-col items-center justify-between pb-20">
|
||||
<div className="relative z-20 py-10 md:pt-40">
|
||||
<Heading as="h1">{t('title')}</Heading>
|
||||
<Subheading className="text-center">
|
||||
{t('subtitle')}
|
||||
</Subheading>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 relative z-20 w-full mb-10">
|
||||
{blogs.slice(0, 2).map((blog, index) => (
|
||||
<BlogCard blog={blog} key={blog.title + index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 w-full relative z-20">
|
||||
{blogs.slice(2).map((blog, index) => (
|
||||
<BlogCard blog={blog} key={blog.title + index} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getCategoryBySlug } from "@/features/tools/actions";
|
||||
import { getLocale } from "next-intl/server";
|
||||
|
||||
interface CategoryPageProps {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CategoryPageProps) {
|
||||
const { slug } = await params;
|
||||
const category = await getCategoryBySlug(slug);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
title: "Category Not Found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${category.nameEn} - AI Tools`,
|
||||
description: category.descriptionEn || `Explore ${category.nameEn} AI tools`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
const { slug } = await params;
|
||||
const locale = await getLocale();
|
||||
const category = await getCategoryBySlug(slug);
|
||||
|
||||
if (!category) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 重定向到工具列表页并带上分类参数
|
||||
redirect(`/${locale}/tools?category=${slug}`);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Background } from "@/components/background";
|
||||
import { Metadata } from "next";
|
||||
import { FeaturedTestimonials } from "@/components/featured-testimonials";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HorizontalGradient } from "@/components/horizontal-gradient";
|
||||
import { ContactForm } from "@/features/marketing/components/contact-form";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'contact' });
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: '/contact',
|
||||
title: t('title'),
|
||||
description: t('subtitle'),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ContactPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'contact' });
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden py-20 md:py-0 px-4 md:px-20 bg-background">
|
||||
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2 relative overflow-hidden">
|
||||
<Background />
|
||||
<ContactForm />
|
||||
<div className="relative w-full z-20 hidden md:flex border-l border-border overflow-hidden bg-background items-center justify-center">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<FeaturedTestimonials />
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-xl text-center text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t('testimonial.title')}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"font-normal text-base text-center text-foreground mt-8"
|
||||
)}
|
||||
>
|
||||
{t('testimonial.description')}
|
||||
</p>
|
||||
</div>
|
||||
<HorizontalGradient className="top-20" />
|
||||
<HorizontalGradient className="bottom-20" />
|
||||
<HorizontalGradient className="-right-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
|
||||
<HorizontalGradient className="-left-80 transform rotate-90 inset-y-0 h-full scale-x-150" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('cookies.title'),
|
||||
description: t('cookies.description'),
|
||||
openGraph: {
|
||||
images: [t('cookies.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<div className="prose prose-gray dark:prose-invert max-w-none">
|
||||
<h1 className="text-4xl font-bold mb-8">Cookie Policy</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Effective Date: [Date]
|
||||
</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
[Your Company Name] ("[Company]", "we", "our", or "us") uses cookies and similar tracking technologies on our website and services. This Cookie Policy explains what cookies are, how we use them, and your choices regarding their use.
|
||||
</p>
|
||||
<p>
|
||||
By continuing to use our website, you consent to our use of cookies as described in this policy. If you do not agree to our use of cookies, please adjust your browser settings or discontinue use of our website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. What Are Cookies?</h2>
|
||||
<p>
|
||||
Cookies are small text files that are placed on your device (computer, smartphone, or tablet) when you visit a website. They are widely used to make websites work more efficiently and provide information to website owners.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Similar technologies include web beacons, pixels, local storage, and other tracking technologies. Throughout this policy, we refer to all of these technologies collectively as "cookies."
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. Types of Cookies We Use</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3">Essential Cookies</h3>
|
||||
<p>
|
||||
These cookies are necessary for the website to function properly. They enable core functionality such as security, authentication, and accessibility. You cannot opt-out of these cookies.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Session cookies:</strong> Maintain your session state</li>
|
||||
<li><strong>Authentication cookies:</strong> Keep you signed in</li>
|
||||
<li><strong>Security cookies:</strong> Protect against CSRF attacks</li>
|
||||
<li><strong>Load balancing cookies:</strong> Distribute traffic across servers</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Functional Cookies</h3>
|
||||
<p>
|
||||
These cookies enable enhanced functionality and personalization. They may be set by us or third-party providers whose services we use.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Preference cookies:</strong> Remember your settings (language, theme)</li>
|
||||
<li><strong>Feature cookies:</strong> Enable specific features you've requested</li>
|
||||
<li><strong>Personalization cookies:</strong> Customize your experience</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Analytics and Performance Cookies</h3>
|
||||
<p>
|
||||
These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Google Analytics:</strong> Track user behavior and site performance</li>
|
||||
<li><strong>Performance monitoring:</strong> Identify site speed issues</li>
|
||||
<li><strong>Error tracking:</strong> Log JavaScript errors and issues</li>
|
||||
<li><strong>A/B testing:</strong> Test different versions of pages</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Marketing and Advertising Cookies</h3>
|
||||
<p>
|
||||
These cookies may be set through our site by advertising partners to build a profile of your interests and show relevant ads on other sites.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Remarketing cookies:</strong> Show relevant ads on other platforms</li>
|
||||
<li><strong>Social media cookies:</strong> Enable social sharing features</li>
|
||||
<li><strong>Conversion tracking:</strong> Measure ad effectiveness</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Specific Cookies We Use</h2>
|
||||
<p>Here are the main cookies we use and their purposes:</p>
|
||||
|
||||
<div className="overflow-x-auto mt-4">
|
||||
<table className="min-w-full border-collapse border border-border">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="border border-border px-4 py-2 text-left">Cookie Name</th>
|
||||
<th className="border border-border px-4 py-2 text-left">Purpose</th>
|
||||
<th className="border border-border px-4 py-2 text-left">Duration</th>
|
||||
<th className="border border-border px-4 py-2 text-left">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-border px-4 py-2">session-token</td>
|
||||
<td className="border border-border px-4 py-2">Authentication</td>
|
||||
<td className="border border-border px-4 py-2">30 days</td>
|
||||
<td className="border border-border px-4 py-2">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-border px-4 py-2">csrf-token</td>
|
||||
<td className="border border-border px-4 py-2">Security</td>
|
||||
<td className="border border-border px-4 py-2">Session</td>
|
||||
<td className="border border-border px-4 py-2">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-border px-4 py-2">theme</td>
|
||||
<td className="border border-border px-4 py-2">UI preferences</td>
|
||||
<td className="border border-border px-4 py-2">1 year</td>
|
||||
<td className="border border-border px-4 py-2">Functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-border px-4 py-2">locale</td>
|
||||
<td className="border border-border px-4 py-2">Language preference</td>
|
||||
<td className="border border-border px-4 py-2">1 year</td>
|
||||
<td className="border border-border px-4 py-2">Functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-border px-4 py-2">_ga, _gid</td>
|
||||
<td className="border border-border px-4 py-2">Google Analytics</td>
|
||||
<td className="border border-border px-4 py-2">2 years / 24 hours</td>
|
||||
<td className="border border-border px-4 py-2">Analytics</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. Third-Party Cookies</h2>
|
||||
<p>
|
||||
We may use third-party services that set their own cookies, including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Authentication providers:</strong> Google, GitHub OAuth services</li>
|
||||
<li><strong>Payment processors:</strong> Creem payment services</li>
|
||||
<li><strong>Analytics providers:</strong> Google Analytics, monitoring services</li>
|
||||
<li><strong>Content delivery networks:</strong> Cloudflare, CDN services</li>
|
||||
<li><strong>Customer support:</strong> Support widget and chat services</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
These third parties have their own privacy policies addressing how they use such information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Your Cookie Choices</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3">Browser Settings</h3>
|
||||
<p>
|
||||
Most web browsers allow you to control cookies through their settings. You can:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>View what cookies are stored on your device</li>
|
||||
<li>Delete cookies individually or entirely</li>
|
||||
<li>Block third-party cookies</li>
|
||||
<li>Block all cookies from specific websites</li>
|
||||
<li>Block all cookies entirely</li>
|
||||
<li>Set preferences for specific websites</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Cookie Management Instructions</h3>
|
||||
<p>Here are links to cookie management instructions for popular browsers:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Chrome:</strong> Settings → Privacy and security → Cookies</li>
|
||||
<li><strong>Firefox:</strong> Settings → Privacy & Security → Cookies</li>
|
||||
<li><strong>Safari:</strong> Preferences → Privacy → Cookies</li>
|
||||
<li><strong>Edge:</strong> Settings → Privacy, search, and services → Cookies</li>
|
||||
<li><strong>Opera:</strong> Settings → Advanced → Privacy & security → Cookies</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Opt-Out Options</h3>
|
||||
<p>
|
||||
You can opt-out of specific types of cookies:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li><strong>Google Analytics:</strong> Install the Google Analytics Opt-out Browser Add-on</li>
|
||||
<li><strong>Advertising cookies:</strong> Visit <a href="https://optout.aboutads.info" className="text-blue-600 hover:underline">optout.aboutads.info</a></li>
|
||||
<li><strong>European users:</strong> Visit <a href="https://www.youronlinechoices.eu" className="text-blue-600 hover:underline">www.youronlinechoices.eu</a></li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Do Not Track</h3>
|
||||
<p>
|
||||
Some browsers offer a "Do Not Track" (DNT) setting. We currently do not respond to DNT signals, but we respect your cookie choices as described above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Impact of Disabling Cookies</h2>
|
||||
<p>
|
||||
Please note that if you choose to block or delete cookies:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Some parts of our website may not function properly</li>
|
||||
<li>You may not be able to access certain features</li>
|
||||
<li>You may need to re-enter information more frequently</li>
|
||||
<li>Your preferences may not be saved</li>
|
||||
<li>You may need to manually adjust settings each visit</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Cookie Policy for Mobile Apps</h2>
|
||||
<p>
|
||||
If we offer mobile applications, they may use similar tracking technologies. Mobile platforms provide device-level settings to control these technologies. Please refer to your device manufacturer's instructions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">9. Updates to This Cookie Policy</h2>
|
||||
<p>
|
||||
We may update this Cookie Policy from time to time to reflect changes in our practices or for legal, operational, or regulatory reasons. We will post any changes on this page with an updated effective date.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We encourage you to review this Cookie Policy periodically to stay informed about our use of cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">10. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about our use of cookies or this Cookie Policy, please contact us at:
|
||||
</p>
|
||||
<ul className="list-none space-y-2 mt-4">
|
||||
<li>Email: [Privacy Email]</li>
|
||||
<li>Support: [Support Email]</li>
|
||||
<li>Website: [Your Website]</li>
|
||||
<li>Address: [Your Address]</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NavBar } from "@/features/navigation/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('home.title'),
|
||||
description: t('home.description'),
|
||||
openGraph: {
|
||||
images: [t('home.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<main>
|
||||
<NavBar />
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Container } from "@/components/container";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { Background } from "@/components/background";
|
||||
import { CTA } from "@/components/cta";
|
||||
import { FeaturedTools } from "@/components/featured-tools";
|
||||
import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations, getLocale } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'hero' });
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: '',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const locale = await getLocale();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
|
||||
// 获取精选工具和分类数据
|
||||
const [{ tools }, categories] = await Promise.all([
|
||||
getPublishedTools({ pageSize: 8 }),
|
||||
getCategoriesWithCount(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Structured Data for SEO */}
|
||||
<WebSiteJsonLd
|
||||
name="Toolso.AI"
|
||||
description="Discover the best AI tools to boost your productivity"
|
||||
url={`${baseUrl}/${locale}`}
|
||||
locale={locale}
|
||||
/>
|
||||
<OrganizationJsonLd
|
||||
name="Toolso.AI"
|
||||
url={baseUrl}
|
||||
logo={`${baseUrl}/banner.png`}
|
||||
sameAs={[
|
||||
'https://coolchange.ai',
|
||||
'https://chatppt.app',
|
||||
'https://aihealthguide.co',
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Background 放在最外层,覆盖整个页面 */}
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<Background />
|
||||
</div>
|
||||
|
||||
{/* Hero 部分 */}
|
||||
<Container className="flex flex-col items-center justify-center pt-32 pb-20 md:pt-40 md:pb-32">
|
||||
<Hero />
|
||||
</Container>
|
||||
|
||||
{/* 精选工具部分 */}
|
||||
<div className="relative bg-background">
|
||||
<Container>
|
||||
<FeaturedTools tools={tools} categories={categories} />
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* CTA 部分 */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<Background />
|
||||
</div>
|
||||
<CTA />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('privacy.title'),
|
||||
description: t('privacy.description'),
|
||||
openGraph: {
|
||||
images: [t('privacy.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<div className="prose prose-gray dark:prose-invert max-w-none">
|
||||
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Effective Date: [Date]
|
||||
</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
[Your Company Name] ("[Company]", "we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you visit our website and use our services.
|
||||
</p>
|
||||
<p>
|
||||
Please read this privacy policy carefully. If you do not agree with the terms of this privacy policy, please do not access the site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Information We Collect</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3">Personal Information</h3>
|
||||
<p>We may collect personal information that you provide directly to us, such as:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Name and contact information (email address, phone number)</li>
|
||||
<li>Account credentials (username, password)</li>
|
||||
<li>Payment information (processed through secure third-party providers)</li>
|
||||
<li>Profile information (avatar, preferences)</li>
|
||||
<li>Communications with us (support tickets, emails)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Automatically Collected Information</h3>
|
||||
<p>When you visit our Service, we automatically collect certain information, including:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Device information (IP address, browser type, operating system)</li>
|
||||
<li>Usage data (pages visited, time spent, clicks)</li>
|
||||
<li>Cookies and similar tracking technologies</li>
|
||||
<li>Log data (access times, referring URLs)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Information from Third-Party Services</h3>
|
||||
<p>If you authenticate using OAuth providers (Google, GitHub, etc.), we may receive:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Basic profile information (name, email, profile picture)</li>
|
||||
<li>Authentication tokens</li>
|
||||
<li>Any additional information you authorize</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Information</h2>
|
||||
<p>We use the information we collect to:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Provide, operate, and maintain our Service</li>
|
||||
<li>Process transactions and manage your account</li>
|
||||
<li>Send administrative information and updates</li>
|
||||
<li>Respond to your comments, questions, and support requests</li>
|
||||
<li>Improve and personalize your experience</li>
|
||||
<li>Monitor and analyze usage patterns and trends</li>
|
||||
<li>Develop new products, services, and features</li>
|
||||
<li>Prevent fraud and enhance security</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Send marketing communications (with your consent)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. How We Share Your Information</h2>
|
||||
<p>We may share your information in the following situations:</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3">With Service Providers</h3>
|
||||
<p>We share information with third-party vendors who perform services on our behalf, such as:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Payment processors (Creem, PayPal)</li>
|
||||
<li>Cloud hosting providers</li>
|
||||
<li>Analytics services</li>
|
||||
<li>Customer support tools</li>
|
||||
<li>Email service providers</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">For Legal Purposes</h3>
|
||||
<p>We may disclose your information when required by law or to:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Respond to lawful requests from public authorities</li>
|
||||
<li>Protect our rights, privacy, safety, or property</li>
|
||||
<li>Enforce our terms and agreements</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Business Transfers</h3>
|
||||
<p>
|
||||
In the event of a merger, acquisition, reorganization, or sale of assets, your information may be transferred as part of that transaction.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">With Your Consent</h3>
|
||||
<p>
|
||||
We may share your information for any other purpose with your explicit consent.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. Data Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational security measures to protect your personal information, including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Encryption of data in transit (SSL/TLS)</li>
|
||||
<li>Encryption of sensitive data at rest</li>
|
||||
<li>Regular security audits and assessments</li>
|
||||
<li>Access controls and authentication requirements</li>
|
||||
<li>Employee training on data protection</li>
|
||||
<li>Incident response procedures</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
However, no method of transmission over the Internet or electronic storage is 100% secure. While we strive to protect your personal information, we cannot guarantee absolute security.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Data Retention</h2>
|
||||
<p>
|
||||
We retain your personal information for as long as necessary to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Provide our services to you</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Resolve disputes and enforce agreements</li>
|
||||
<li>Maintain business records</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
When we no longer need your information, we will securely delete or anonymize it in accordance with our data retention policies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Your Rights and Choices</h2>
|
||||
<p>Depending on your location, you may have the following rights regarding your personal information:</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3">Access and Portability</h3>
|
||||
<p>You can request access to your personal information and receive a copy in a structured, machine-readable format.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Correction and Update</h3>
|
||||
<p>You can request that we correct or update inaccurate or incomplete personal information.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Deletion</h3>
|
||||
<p>You can request deletion of your personal information, subject to certain exceptions.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Objection and Restriction</h3>
|
||||
<p>You can object to or request that we restrict processing of your personal information.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Marketing Communications</h3>
|
||||
<p>You can opt-out of marketing emails by clicking the unsubscribe link or contacting us directly.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4">Cookies</h3>
|
||||
<p>You can manage cookie preferences through your browser settings. See our Cookie Policy for more information.</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. International Data Transfers</h2>
|
||||
<p>
|
||||
Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws than your country.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We take appropriate safeguards to ensure that your personal information remains protected in accordance with this Privacy Policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">9. California Privacy Rights (CCPA)</h2>
|
||||
<p>
|
||||
If you are a California resident, you have specific rights under the California Consumer Privacy Act (CCPA), including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>The right to know what personal information we collect, use, and disclose</li>
|
||||
<li>The right to request deletion of your personal information</li>
|
||||
<li>The right to opt-out of the sale of personal information (we do not sell personal information)</li>
|
||||
<li>The right to non-discrimination for exercising your privacy rights</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">10. European Privacy Rights (GDPR)</h2>
|
||||
<p>
|
||||
If you are located in the European Economic Area (EEA) or United Kingdom, you have additional rights under the General Data Protection Regulation (GDPR), including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>The right to be informed about data processing</li>
|
||||
<li>The right to rectification of inaccurate data</li>
|
||||
<li>The right to erasure ("right to be forgotten")</li>
|
||||
<li>The right to data portability</li>
|
||||
<li>The right to object to processing</li>
|
||||
<li>Rights related to automated decision-making</li>
|
||||
<li>The right to lodge a complaint with supervisory authorities</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">11. Children's Privacy</h2>
|
||||
<p>
|
||||
Our Service is not intended for children under 18 years of age. We do not knowingly collect personal information from children under 18. If you become aware that a child has provided us with personal information, please contact us immediately.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">12. Changes to This Privacy Policy</h2>
|
||||
<p>
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Effective Date" at the top.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
For significant changes, we will provide additional notice through email or a prominent notice on our Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">13. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions or concerns about this Privacy Policy or our data practices, please contact us at:
|
||||
</p>
|
||||
<ul className="list-none space-y-2 mt-4">
|
||||
<li>Email: [Privacy Email]</li>
|
||||
<li>Support: [Support Email]</li>
|
||||
<li>Website: [Your Website]</li>
|
||||
<li>Address: [Your Address]</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
For data protection inquiries, you may also contact our Data Protection Officer at: [DPO Email]
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'seo' });
|
||||
|
||||
return {
|
||||
title: t('terms.title'),
|
||||
description: t('terms.description'),
|
||||
openGraph: {
|
||||
images: [t('terms.ogImage')],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<div className="prose prose-gray dark:prose-invert max-w-none">
|
||||
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Effective Date: [Date]
|
||||
</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to [Your Company Name] ("[Company]", "we", "our", or "us"). These Terms of Service ("Terms") govern your use of our website and services (collectively, the "Service") located at [Your Domain].
|
||||
</p>
|
||||
<p>
|
||||
By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Service Description</h2>
|
||||
<p>
|
||||
[Your Company Name] provides [describe your service/product]. Our Service allows you to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>[Feature/capability 1]</li>
|
||||
<li>[Feature/capability 2]</li>
|
||||
<li>[Feature/capability 3]</li>
|
||||
<li>[Additional features as needed]</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. User Accounts</h2>
|
||||
<p>
|
||||
To access certain features of our Service, you may be required to create an account. When creating an account, you must:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Provide accurate, current, and complete information</li>
|
||||
<li>Maintain the security of your password and account</li>
|
||||
<li>Promptly update your account information to keep it accurate</li>
|
||||
<li>Accept all risks of unauthorized access to your account</li>
|
||||
<li>Be at least 18 years old or the age of legal consent in your jurisdiction</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
You are responsible for all activities that occur under your account. We reserve the right to refuse service, terminate accounts, or remove content at our sole discretion.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Acceptable Use</h2>
|
||||
<p>
|
||||
You agree not to use the Service to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Violate any laws or regulations</li>
|
||||
<li>Infringe upon the rights of others</li>
|
||||
<li>Upload or transmit viruses or malicious code</li>
|
||||
<li>Engage in any activity that disrupts or interferes with the Service</li>
|
||||
<li>Attempt to gain unauthorized access to any portion of the Service</li>
|
||||
<li>Harass, abuse, or harm another person</li>
|
||||
<li>Use the Service for any illegal or unauthorized purpose</li>
|
||||
<li>Violate any applicable laws in your jurisdiction</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. Intellectual Property Rights</h2>
|
||||
<p>
|
||||
The Service and its original content, features, and functionality are and will remain the exclusive property of [Your Company Name] and its licensors. The Service is protected by copyright, trademark, and other laws. Our trademarks and trade dress may not be used in connection with any product or service without our prior written consent.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
You retain ownership of any content you submit to the Service. By submitting content, you grant us a worldwide, non-exclusive, royalty-free license to use, reproduce, modify, and distribute your content in connection with operating and providing the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Service Usage</h2>
|
||||
<p>
|
||||
When using our Service, you agree to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Use the Service in accordance with these Terms</li>
|
||||
<li>Not attempt to circumvent any security measures</li>
|
||||
<li>Not use automated systems to access the Service without permission</li>
|
||||
<li>Report any security vulnerabilities you discover</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Disclaimers and Limitations of Liability</h2>
|
||||
<p>
|
||||
THE SERVICE IS PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS. WE DISCLAIM ALL WARRANTIES, WHETHER EXPRESS OR IMPLIED, INCLUDING THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
IN NO EVENT SHALL [YOUR COMPANY NAME], ITS DIRECTORS, EMPLOYEES, PARTNERS, AGENTS, OR AFFILIATES BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING WITHOUT LIMITATION, LOSS OF PROFITS, DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Indemnification</h2>
|
||||
<p>
|
||||
You agree to defend, indemnify, and hold harmless [Your Company Name] and its affiliates from and against any claims, liabilities, damages, judgments, awards, losses, costs, expenses, or fees arising out of or relating to your violation of these Terms or your use of the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">9. Termination</h2>
|
||||
<p>
|
||||
We may terminate or suspend your account and access to the Service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Upon termination, your right to use the Service will immediately cease. All provisions of the Terms which by their nature should survive termination shall survive termination.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">10. Privacy Policy</h2>
|
||||
<p>
|
||||
Your use of the Service is also governed by our Privacy Policy. Please review our Privacy Policy, which also governs the Site and informs users of our data collection practices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">11. Changes to Terms</h2>
|
||||
<p>
|
||||
We reserve the right to modify or replace these Terms at any time. If a revision is material, we will provide at least 30 days notice prior to any new terms taking effect.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">12. Governing Law</h2>
|
||||
<p>
|
||||
These Terms shall be governed and construed in accordance with the laws of [Your Jurisdiction], without regard to its conflict of law provisions. Any legal action or proceeding arising under these Terms will be brought exclusively in courts located in [Your Jurisdiction].
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">13. Contact Information</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:
|
||||
</p>
|
||||
<ul className="list-none space-y-2 mt-4">
|
||||
<li>Email: [Your Email]</li>
|
||||
<li>Website: [Your Website]</li>
|
||||
<li>Address: [Your Address]</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getToolBySlug } from "@/features/tools/actions";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { SoftwareApplicationJsonLd } from "@/components/json-ld";
|
||||
|
||||
interface ToolDetailPageProps {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("tools");
|
||||
const tool = await getToolBySlug(slug);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
title: t("notFound"),
|
||||
};
|
||||
}
|
||||
|
||||
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||
const description =
|
||||
locale === "zh" && tool.descriptionZh
|
||||
? tool.descriptionZh
|
||||
: tool.descriptionEn;
|
||||
|
||||
return {
|
||||
title: `${name} - ${t("title")}`,
|
||||
description: description || `${t("subtitle")} - ${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("tools");
|
||||
const tool = await getToolBySlug(slug);
|
||||
|
||||
if (!tool) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||
const description =
|
||||
locale === "zh" && tool.descriptionZh
|
||||
? tool.descriptionZh
|
||||
: tool.descriptionEn;
|
||||
|
||||
const getCategoryName = (category: { nameEn: string; nameZh: string | null }) =>
|
||||
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
||||
|
||||
const getTagName = (tag: { nameEn: string; nameZh: string | null }) =>
|
||||
locale === "zh" && tag.nameZh ? tag.nameZh : tag.nameEn;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
const primaryCategory = tool.categories?.[0]?.nameEn;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 pt-24 pb-12">
|
||||
{/* Structured Data for AI Tool */}
|
||||
<SoftwareApplicationJsonLd
|
||||
name={name}
|
||||
description={description || ''}
|
||||
url={tool.websiteUrl || `${baseUrl}/${locale}/tools/${slug}`}
|
||||
image={tool.logoUrl || tool.coverImageUrl || undefined}
|
||||
category={primaryCategory}
|
||||
applicationCategory="AI Tool"
|
||||
/>
|
||||
|
||||
{/* 返回按钮 */}
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("backToTools")}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* 主内容区 */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* 工具头部 */}
|
||||
<div className="flex items-start gap-6">
|
||||
{tool.logoUrl ? (
|
||||
<img
|
||||
src={tool.logoUrl}
|
||||
alt={name}
|
||||
className="h-20 w-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 w-20 rounded-2xl bg-secondary flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-muted-foreground">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
{name}
|
||||
</h1>
|
||||
{tool.domain && (
|
||||
<p className="text-muted-foreground">{tool.domain}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
{tool.coverImageUrl && (
|
||||
<div className="rounded-xl overflow-hidden border border-border">
|
||||
<img
|
||||
src={tool.coverImageUrl}
|
||||
alt={name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
{description && (
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("about")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类 */}
|
||||
{tool.categories && tool.categories.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("categories")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/${locale}/tools?category=${category.slug}`}
|
||||
className="px-4 py-2 rounded-lg bg-secondary text-foreground hover:bg-hover transition-colors"
|
||||
>
|
||||
{category.icon && (
|
||||
<span className="mr-2">{category.icon}</span>
|
||||
)}
|
||||
{getCategoryName(category)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{tool.tags && tool.tags.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("tags")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-secondary text-sm text-muted-foreground"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{getTagName(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
{/* 访问按钮 */}
|
||||
{tool.websiteUrl && (
|
||||
<a
|
||||
href={tool.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t("visitButton")}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 工具信息卡片 */}
|
||||
<div className="bg-secondary rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{t("toolInfo")}
|
||||
</h3>
|
||||
|
||||
{tool.domain && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domain")}
|
||||
</p>
|
||||
<p className="text-foreground">{tool.domain}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("added")}
|
||||
</p>
|
||||
<p className="text-foreground">
|
||||
{new Date(tool.createdAt).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { ToolsList } from "@/features/tools/components/tools-list";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("seo.tools");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolsPageProps {
|
||||
searchParams: Promise<{
|
||||
page?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
const params = await searchParams;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("tools");
|
||||
const page = parseInt(params.page || "1", 10);
|
||||
const categorySlug = params.category;
|
||||
const search = params.search;
|
||||
|
||||
// 获取分类列表用于筛选
|
||||
const categories = await getCategoriesWithCount();
|
||||
|
||||
// 找到对应的 categoryId
|
||||
let categoryId: string | undefined;
|
||||
if (categorySlug) {
|
||||
const category = categories.find((c) => c.slug === categorySlug);
|
||||
categoryId = category?.id;
|
||||
}
|
||||
|
||||
// 获取工具列表
|
||||
const { tools, total, pageSize } = await getPublishedTools({
|
||||
page,
|
||||
pageSize: 12,
|
||||
categoryId,
|
||||
search,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 准备翻译文本传递给客户端组件
|
||||
const translations = {
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
searchPlaceholder: t("searchPlaceholder"),
|
||||
all: t("all"),
|
||||
foundTools: t("foundTools", { count: total }),
|
||||
visitWebsite: t("visitWebsite"),
|
||||
noTools: t("noTools"),
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{/* Structured Data for AI Tools Directory */}
|
||||
<CollectionPageJsonLd
|
||||
name={translations.title}
|
||||
description={translations.subtitle}
|
||||
url={`${baseUrl}/${locale}/tools`}
|
||||
itemCount={total}
|
||||
/>
|
||||
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||
{translations.title}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{translations.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToolsList
|
||||
tools={tools}
|
||||
categories={categories}
|
||||
currentCategory={categorySlug}
|
||||
currentSearch={search}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
locale={locale}
|
||||
translations={translations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useSession, signOut } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/button";
|
||||
import { Container } from "@/components/container";
|
||||
import { Background } from "@/components/background";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// Authentication is already handled in the layout
|
||||
const user = session.data?.user;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<Background />
|
||||
<Container className="relative z-10 py-20">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<Background />
|
||||
<Container className="relative z-10 py-20">
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-foreground mb-4">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-12">
|
||||
{t("welcome")}, {user?.name || user?.email}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
>
|
||||
{/* Personal Info Card */}
|
||||
<div className="bg-card/50 backdrop-blur-md rounded-2xl p-6 border border-border">
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-4">
|
||||
{t("cards.personalInfo.title")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("cards.personalInfo.labels.name")}
|
||||
</span>
|
||||
<span className="text-base font-medium text-card-foreground">
|
||||
{user?.name || t("cards.personalInfo.labels.notSet")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("cards.personalInfo.labels.email")}
|
||||
</span>
|
||||
<span className="text-base font-medium text-card-foreground">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("cards.personalInfo.labels.status")}
|
||||
</span>
|
||||
<span className="text-base font-medium text-green-500">
|
||||
{tCommon("status.verified")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<div className="bg-card/50 backdrop-blur-md rounded-2xl p-6 border border-border">
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-4">
|
||||
{t("cards.quickActions.title")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start transition-colors"
|
||||
onClick={() => router.push(`/${locale}/profile`)}
|
||||
>
|
||||
{t("cards.quickActions.editProfile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start transition-colors"
|
||||
>
|
||||
{t("cards.quickActions.accountSettings")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start transition-colors"
|
||||
>
|
||||
{t("cards.quickActions.securitySettings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.3 }}
|
||||
className="mt-12 flex justify-center"
|
||||
>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
variant="simple"
|
||||
className="px-8"
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EmailVerifiedGuard } from "@/features/auth/components/email-verified-guard";
|
||||
import { NavBar } from "@/features/navigation/components/navbar";
|
||||
|
||||
export default function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<EmailVerifiedGuard requireEmailVerification={true}>
|
||||
<main className="min-h-screen">
|
||||
<NavBar />
|
||||
{children}
|
||||
</main>
|
||||
</EmailVerifiedGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import Image from "next/image";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Container } from "@/components/container";
|
||||
import { Background } from "@/components/background";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("profile");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// Authentication is already handled in the layout
|
||||
const user = session.data?.user;
|
||||
const initial = user?.name
|
||||
? user.name.charAt(0).toUpperCase()
|
||||
: user?.email?.charAt(0).toUpperCase() || "U";
|
||||
|
||||
// Format registration date
|
||||
const formatMemberSince = () => {
|
||||
if (!user?.createdAt) return "-";
|
||||
const date = new Date(user.createdAt);
|
||||
return date.toLocaleDateString(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<Background />
|
||||
<Container className="relative z-10 py-20">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<Background />
|
||||
<Container className="relative z-10 py-20">
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-foreground mb-4">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.1 }}
|
||||
className="bg-card/50 backdrop-blur-md rounded-3xl p-8 border border-border"
|
||||
>
|
||||
{/* Avatar Section */}
|
||||
<div className="flex flex-col items-center mb-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.2 }}
|
||||
className="w-32 h-32 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl mb-6 ring-4 ring-border/50"
|
||||
>
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user?.name || "User"}
|
||||
width={128}
|
||||
height={128}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</motion.div>
|
||||
<h2 className="text-3xl font-bold text-card-foreground mb-2">
|
||||
{user?.name || t("sections.basicInfo.nameNotSet")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Information Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Basic Information */}
|
||||
<motion.div
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-4">
|
||||
{t("sections.basicInfo.title")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-xl p-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
{t("sections.basicInfo.fullName")}
|
||||
</span>
|
||||
<span className="text-lg font-medium text-card-foreground">
|
||||
{user?.name || t("sections.basicInfo.notSet")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-xl p-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
{t("sections.basicInfo.email")}
|
||||
</span>
|
||||
<span className="text-lg font-medium text-card-foreground">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-xl p-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
{t("sections.basicInfo.verificationStatus")}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-500/10 text-green-600">
|
||||
{tCommon("status.verified")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Account Information */}
|
||||
<motion.div
|
||||
initial={{ x: 20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ ease: "easeOut", duration: 0.5, delay: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-4">
|
||||
{t("sections.accountSettings.title")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-xl p-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
{t("sections.accountSettings.memberSince")}
|
||||
</span>
|
||||
<span className="text-lg font-medium text-card-foreground">
|
||||
{formatMemberSince()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-xl p-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
{t("sections.basicInfo.accountId")}
|
||||
</span>
|
||||
<span className="text-lg font-medium text-card-foreground font-mono">
|
||||
{user?.id || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
export default function CheckEmailPage() {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.checkEmail');
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendMessage, setResendMessage] = useState('');
|
||||
|
||||
const handleResendEmail = async () => {
|
||||
try {
|
||||
setIsResending(true);
|
||||
setResendMessage('');
|
||||
|
||||
const response = await fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setResendMessage(t('resendSuccess'));
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setResendMessage(data.error || t('resendError'));
|
||||
}
|
||||
} catch (error) {
|
||||
setResendMessage(t('resendError'));
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<div className="max-w-md w-full mx-auto p-6">
|
||||
<div className="bg-card rounded-2xl shadow-lg border border-border p-8">
|
||||
<div className="text-center">
|
||||
{/* Email Icon with Animation */}
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-muted-foreground/20 rounded-full opacity-20 animate-ping"></div>
|
||||
<Mail className="w-20 h-20 mx-auto mb-6 text-card-foreground relative" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold text-card-foreground mb-3">
|
||||
{t('title')}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground mb-8 leading-relaxed">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
{/* Email Illustration */}
|
||||
<div className="bg-muted rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t('notReceived')}
|
||||
</p>
|
||||
<ul className="text-left text-sm text-card-foreground space-y-2">
|
||||
<li className="flex items-start">
|
||||
<ArrowRight className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span>{t('checkSpam')}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<ArrowRight className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span>{t('checkEmailCorrect')}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<ArrowRight className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<span>{t('waitAndCheck')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resend Button */}
|
||||
<button
|
||||
onClick={handleResendEmail}
|
||||
disabled={isResending}
|
||||
className="w-full mb-4 px-6 py-3 bg-foreground hover:bg-foreground/90 disabled:bg-muted-foreground text-background font-medium rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isResending ? (
|
||||
<>
|
||||
<RefreshCcw className="w-5 h-5 mr-2 animate-spin" />
|
||||
{t('resending')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcw className="w-5 h-5 mr-2" />
|
||||
{t('resendButton')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Resend Message */}
|
||||
{resendMessage && (
|
||||
<p className={`text-sm mb-4 ${
|
||||
resendMessage.includes('successfully')
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{resendMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Back to Login */}
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Security Note */}
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('securityNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { locales } from "@/i18n.config";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
import { ThemeProvider } from "@/context/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import Analytics from "@/analytics/analytics";
|
||||
import "../globals.css";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!locales.includes(locale as any)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
const messages = await getMessages({ locale });
|
||||
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
GeistSans.className,
|
||||
"bg-background text-foreground antialiased min-h-screen w-full"
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
<Analytics />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.verifyEmail');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const verifyEmail = async () => {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setMessage('Invalid verification link. Please check your email for the correct link.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/verify-email?token=${token}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('success');
|
||||
setMessage('Your email has been successfully verified! Redirecting to login...');
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push(`/${locale}/login`);
|
||||
}, 3000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage(data.error || 'Verification failed. The link may have expired.');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setMessage('An error occurred while verifying your email. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
verifyEmail();
|
||||
}, [searchParams, router, locale]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-muted to-background">
|
||||
<div className="max-w-md w-full mx-auto p-6">
|
||||
<div className="bg-card rounded-2xl shadow-xl p-8 border border-border">
|
||||
{status === 'loading' && (
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-16 h-16 mx-auto mb-4 text-blue-500 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{t('verifying')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('verifyingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-green-500" />
|
||||
<h1 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{t('success')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-center">
|
||||
{message.includes('already been used') || message.includes('already verified') ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{t('alreadyVerified')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-16 h-16 mx-auto mb-4 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{t('failed')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/check-email`}
|
||||
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('requestNew')}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 pt-6 border-t border-border">
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
<span>{t('checkSpamNote')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { user, verification, account } from "@/lib/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sendPasswordResetEmail } from "@/lib/email";
|
||||
import crypto from "crypto";
|
||||
|
||||
const VERIFICATION_TYPE = "password_reset";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email } = await request.json();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser || existingUser.length === 0) {
|
||||
// Don't reveal if email exists or not
|
||||
return NextResponse.json(
|
||||
{ message: "If the email exists, a reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const foundUser = existingUser[0];
|
||||
|
||||
// Ensure user has a credential account before sending email
|
||||
const credentialAccount = await db
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, foundUser.id),
|
||||
eq(account.providerId, "credential")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (credentialAccount.length === 0) {
|
||||
// Return generic response to avoid leaking account type
|
||||
return NextResponse.json(
|
||||
{ message: "If the email exists, a reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
||||
const hashedToken = crypto
|
||||
.createHash("sha256")
|
||||
.update(resetToken)
|
||||
.digest("hex");
|
||||
|
||||
// Set token expiry to 1 hour from now
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// Remove any existing password reset tokens for the user
|
||||
await db
|
||||
.delete(verification)
|
||||
.where(
|
||||
and(
|
||||
eq(verification.identifier, `${VERIFICATION_TYPE}:${foundUser.id}`),
|
||||
)
|
||||
);
|
||||
|
||||
// Store token in verification table
|
||||
await db.insert(verification).values({
|
||||
id: crypto.randomUUID(),
|
||||
identifier: `${VERIFICATION_TYPE}:${foundUser.id}`,
|
||||
value: hashedToken,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Send reset email
|
||||
const result = await sendPasswordResetEmail(email, resetToken);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to send password reset email:", result.error);
|
||||
return NextResponse.json(
|
||||
{ message: "Failed to send reset email. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "If the email exists, a reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "An error occurred. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { user, verification } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sendVerificationEmail } from "@/lib/email";
|
||||
import crypto from "crypto";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get current session
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session || !session.user) {
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if user is already verified
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser || existingUser.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = existingUser[0];
|
||||
|
||||
if (currentUser.emailVerified) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email is already verified" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new verification token
|
||||
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
// Remove existing verification token before inserting a new one
|
||||
await db
|
||||
.delete(verification)
|
||||
.where(eq(verification.id, `email-verification:${userId}`));
|
||||
|
||||
// Store verification token
|
||||
await db.insert(verification).values({
|
||||
id: `email-verification:${userId}`,
|
||||
identifier: "email-verification",
|
||||
value: verificationToken,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
const result = await sendVerificationEmail(currentUser.email, verificationToken);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ message: "Failed to send verification email" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Verification email sent successfully" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Resend verification error:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "An error occurred. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { verification, account } from "@/lib/db/schema";
|
||||
import { eq, and, gt, like } from "drizzle-orm";
|
||||
import crypto from "crypto";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
|
||||
const VERIFICATION_TYPE = "password_reset";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { token, password } = await request.json();
|
||||
|
||||
if (!token || !password) {
|
||||
return NextResponse.json(
|
||||
{ message: "Token and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ message: "Password must be at least 8 characters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the token to match what's in database
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
|
||||
// Find valid token in verification table
|
||||
const resetToken = await db
|
||||
.select()
|
||||
.from(verification)
|
||||
.where(
|
||||
and(
|
||||
eq(verification.value, hashedToken),
|
||||
like(verification.identifier, `${VERIFICATION_TYPE}:%`),
|
||||
gt(verification.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resetToken || resetToken.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid or expired token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = resetToken[0];
|
||||
// Extract userId from identifier (format: "password_reset:userId")
|
||||
const userId = tokenData.identifier.replace(`${VERIFICATION_TYPE}:`, "");
|
||||
|
||||
// Ensure user has a credential account
|
||||
const credentialAccount = await db
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, userId),
|
||||
eq(account.providerId, "credential")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (credentialAccount.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Password reset is not available for this account" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Update user's password in the account table and ensure the update succeeds
|
||||
const updatedAccounts = await db
|
||||
.update(account)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, userId),
|
||||
eq(account.providerId, "credential")
|
||||
)
|
||||
)
|
||||
.returning({ id: account.id });
|
||||
|
||||
if (updatedAccounts.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Failed to update password" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the used token
|
||||
await db
|
||||
.delete(verification)
|
||||
.where(eq(verification.identifier, tokenData.identifier));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Password reset successfully" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "An error occurred. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { user, verification } from "@/lib/db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Verification token is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
console.log("Verifying token:", token);
|
||||
|
||||
// First, try to find the verification token
|
||||
const verificationRecord = await db
|
||||
.select()
|
||||
.from(verification)
|
||||
.where(
|
||||
eq(verification.value, token)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
console.log("Verification record found:", verificationRecord);
|
||||
|
||||
// If no record found, check if it's because the user is already verified
|
||||
if (!verificationRecord || verificationRecord.length === 0) {
|
||||
// Token not found - could be already used or invalid
|
||||
// Let's be more user-friendly with the error message
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "This verification link has already been used or is invalid. If your email is already verified, you can proceed to login."
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
const record = verificationRecord[0];
|
||||
if (record.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "This verification link has expired. Please request a new one." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's an email verification token
|
||||
if (record.identifier !== "email-verification") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid verification token type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract user ID from verification record
|
||||
// The ID format should be "email-verification:userId"
|
||||
const parts = record.id.split(":");
|
||||
const userId = parts.length > 1 ? parts[1] : parts[0];
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid verification token format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update user's email verification status
|
||||
const updateResult = await db
|
||||
.update(user)
|
||||
.set({
|
||||
emailVerified: true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
// Delete the used verification token
|
||||
await db
|
||||
.delete(verification)
|
||||
.where(eq(verification.id, record.id));
|
||||
|
||||
// Return success response (not redirect, as this is called via fetch)
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Email verified successfully" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Email verification error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "An error occurred during verification" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { verification } from "@/lib/db/schema";
|
||||
import { eq, and, gt, like } from "drizzle-orm";
|
||||
import crypto from "crypto";
|
||||
|
||||
const VERIFICATION_TYPE = "password_reset";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, message: "Token is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the token to match what's in database
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
|
||||
// Find valid token in verification table
|
||||
const validToken = await db
|
||||
.select()
|
||||
.from(verification)
|
||||
.where(
|
||||
and(
|
||||
eq(verification.value, hashedToken),
|
||||
like(verification.identifier, `${VERIFICATION_TYPE}:%`),
|
||||
gt(verification.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!validToken || validToken.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, message: "Invalid or expired token" },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ valid: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Token verification error:", error);
|
||||
return NextResponse.json(
|
||||
{ valid: false, message: "An error occurred" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { uploadImageFromUrl } from "@/lib/r2-storage";
|
||||
|
||||
// Simple image dimension detection for common formats
|
||||
async function getImageDimensions(buffer: Buffer, mimeType: string): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
// For JPEG
|
||||
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
||||
// JPEG starts with FF D8 FF
|
||||
if (buffer[0] === 0xFF && buffer[1] === 0xD8) {
|
||||
let offset = 2;
|
||||
while (offset < buffer.length) {
|
||||
if (buffer[offset] !== 0xFF) break;
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
if (marker === 0xC0 || marker === 0xC2) { // SOF0 or SOF2
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
offset += 2 + buffer.readUInt16BE(offset + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For PNG
|
||||
if (mimeType === 'image/png') {
|
||||
// PNG starts with 89 50 4E 47
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// For WebP
|
||||
if (mimeType === 'image/webp') {
|
||||
// WebP starts with RIFF
|
||||
if (buffer.toString('utf8', 0, 4) === 'RIFF') {
|
||||
const width = buffer.readUInt16LE(26) + 1;
|
||||
const height = buffer.readUInt16LE(28) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error detecting image dimensions:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await auth.api.getSession({
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
if (!session?.session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.session.userId;
|
||||
|
||||
// Parse form data
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File size must be less than 10MB" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convert File to buffer
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// For video generation, check minimum image dimensions
|
||||
// Volcano Engine requires minimum 300px height
|
||||
try {
|
||||
// Simple dimension check for common formats
|
||||
const dimensions = await getImageDimensions(buffer, file.type);
|
||||
if (dimensions && (dimensions.width < 300 || dimensions.height < 300)) {
|
||||
return NextResponse.json({
|
||||
error: "Image must be at least 300x300 pixels for video generation"
|
||||
}, { status: 400 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not validate image dimensions:", err);
|
||||
// Continue anyway if we can't determine dimensions
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(7);
|
||||
const extension = file.type.split('/')[1] || 'png';
|
||||
const filename = `uploads/${userId}/${timestamp}_${random}.${extension}`;
|
||||
|
||||
// For now, save to a temporary location
|
||||
// In production, you would upload to R2 or other storage
|
||||
// Since we already have R2 configured, let's use it
|
||||
const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
const STORAGE_ACCESS_KEY_ID = process.env.STORAGE_ACCESS_KEY_ID || '';
|
||||
const STORAGE_SECRET_ACCESS_KEY = process.env.STORAGE_SECRET_ACCESS_KEY || '';
|
||||
const STORAGE_ENDPOINT = process.env.STORAGE_ENDPOINT || '';
|
||||
const STORAGE_BUCKET_NAME = process.env.STORAGE_BUCKET_NAME || 'toolso-uploads';
|
||||
const STORAGE_PUBLIC_URL = process.env.STORAGE_PUBLIC_URL || '';
|
||||
|
||||
if (!STORAGE_ACCESS_KEY_ID || !STORAGE_SECRET_ACCESS_KEY) {
|
||||
// If R2 is not configured, return a data URL for testing
|
||||
const base64 = buffer.toString('base64');
|
||||
const dataUrl = `data:${file.type};base64,${base64}`;
|
||||
return NextResponse.json({ url: dataUrl });
|
||||
}
|
||||
|
||||
// Extract endpoint URL from the full endpoint if needed
|
||||
const getEndpointUrl = () => {
|
||||
if (STORAGE_ENDPOINT.includes('.r2.cloudflarestorage.com')) {
|
||||
const parts = STORAGE_ENDPOINT.split('/');
|
||||
return parts[0] + '//' + parts[2];
|
||||
}
|
||||
return STORAGE_ENDPOINT;
|
||||
};
|
||||
|
||||
const r2Client = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: getEndpointUrl(),
|
||||
credentials: {
|
||||
accessKeyId: STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: STORAGE_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: STORAGE_BUCKET_NAME,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
});
|
||||
|
||||
await r2Client.send(command);
|
||||
|
||||
const publicUrl = `${STORAGE_PUBLIC_URL}/${filename}`;
|
||||
|
||||
return NextResponse.json({
|
||||
url: publicUrl,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
return NextResponse.json({
|
||||
error: error.message || "Failed to upload file"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await auth.api.getSession({
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
if (!session?.session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File size must be less than 5MB" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convert to base64 data URL for temporary use
|
||||
// This is a simple solution that works for small images
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
const base64 = buffer.toString('base64');
|
||||
const dataUrl = `data:${file.type};base64,${base64}`;
|
||||
|
||||
// In production, you would upload to a proper storage service
|
||||
// For now, we'll use a public image service or return the data URL
|
||||
|
||||
// Option 1: Use a free image hosting service (like imgur)
|
||||
// Option 2: Return data URL (works for small images)
|
||||
// Option 3: Use an existing public image for testing
|
||||
|
||||
// For testing, let's use a sample public image URL
|
||||
// You can replace this with actual upload logic later
|
||||
const testImageUrl = "https://ark-project.tos-cn-beijing.volces.com/doc_image/seepro_i2v.png";
|
||||
|
||||
console.log('Image uploaded (using test URL for now)');
|
||||
|
||||
return NextResponse.json({
|
||||
url: testImageUrl, // Use test URL for now
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
message: "Using test image URL for demo. In production, this would upload to real storage."
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
return NextResponse.json({
|
||||
error: error.message || "Failed to upload file"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session || !session.user) {
|
||||
return NextResponse.json({ isAdmin: false });
|
||||
}
|
||||
|
||||
const dbUser = await db
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1);
|
||||
|
||||
const isAdmin = dbUser[0]?.role === "admin";
|
||||
|
||||
return NextResponse.json({ isAdmin });
|
||||
} catch (error) {
|
||||
console.error("Error checking admin status:", error);
|
||||
return NextResponse.json({ isAdmin: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { user as userTable } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Get session from Better Auth
|
||||
const session = await auth.api.getSession({
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
if (!session?.session?.userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.session.userId;
|
||||
|
||||
// Get user profile
|
||||
const users = await db
|
||||
.select({
|
||||
id: userTable.id,
|
||||
name: userTable.name,
|
||||
email: userTable.email,
|
||||
emailVerified: userTable.emailVerified,
|
||||
image: userTable.image,
|
||||
createdAt: userTable.createdAt,
|
||||
})
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, userId));
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
return NextResponse.json({ user });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user profile:", error);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,106 @@
|
||||
@import "./prism.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* 主要背景和前景色 */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
/* 卡片容器 */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
/* 弹出层 */
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
/* 主色调 (用于主要按钮、链接等) */
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
/* 次要色调 */
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
/* 柔和/禁用状态 */
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 35%;
|
||||
|
||||
/* 强调色 */
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
/* 边框、输入框、焦点环 */
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
/* 渐变色支持 */
|
||||
--gradient-from: 0 0% 96.1%;
|
||||
--gradient-to: 0 0% 89.8%;
|
||||
|
||||
/* 导航栏特定颜色 */
|
||||
--navbar-bg: 0 0% 98%;
|
||||
--navbar-bg-scrolled: 0 0% 96.1%;
|
||||
|
||||
/* 阴影颜色 (用于导航栏、卡片等) */
|
||||
--shadow-color: 0 0% 89.8%;
|
||||
|
||||
/* 悬停状态 */
|
||||
--hover-bg: 0 0% 96.1%;
|
||||
--hover-foreground: 0 0% 0%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 70%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
|
||||
--gradient-from: 0 0% 3.9%;
|
||||
--gradient-to: 0 0% 14.9%;
|
||||
|
||||
--navbar-bg: 0 0% 11.8%;
|
||||
--navbar-bg-scrolled: 0 0% 14.9%;
|
||||
|
||||
--shadow-color: 0 0% 14.9%;
|
||||
|
||||
--hover-bg: 0 0% 14.9%;
|
||||
--hover-foreground: 0 0% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 80px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="16" cy="16" r="14" fill="#000"/>
|
||||
<path d="M10 10h12M16 10v12" stroke="#FFF" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<circle cx="23" cy="9" r="2" fill="#FFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,9 @@
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Toolso.AI - AI Tools Directory',
|
||||
short_name: 'Toolso.AI',
|
||||
description: 'Discover the best AI tools to boost your productivity. Browse our curated directory of AI tools across various categories.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
categories: ['productivity', 'utilities', 'business', 'education'],
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
import { defaultLocale } from '@/i18n.config';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div style={{ textAlign: 'center', padding: '100px' }}>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you are looking for doesn't exist.</p>
|
||||
<Link href={`/${defaultLocale}`}>
|
||||
Go to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
pre[class*="language-"] {
|
||||
color: theme("colors.zinc.100");
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.class-name,
|
||||
.token.selector,
|
||||
.token.selector .class,
|
||||
.token.selector.class,
|
||||
.token.function {
|
||||
color: theme("colors.blue.400");
|
||||
}
|
||||
|
||||
.token.attr-name,
|
||||
.token.keyword,
|
||||
.token.rule,
|
||||
.token.pseudo-class,
|
||||
.token.important {
|
||||
color: theme("colors.slate.300");
|
||||
}
|
||||
|
||||
.token.module {
|
||||
color: theme("colors.emerald.400");
|
||||
}
|
||||
|
||||
.token.attr-value,
|
||||
.token.class,
|
||||
.token.string,
|
||||
.token.property {
|
||||
color: theme("colors.teal.300");
|
||||
}
|
||||
|
||||
.token.punctuation,
|
||||
.token.attr-equals {
|
||||
color: theme("colors.zinc.500");
|
||||
}
|
||||
|
||||
.token.unit,
|
||||
.language-css .token.function {
|
||||
color: theme("colors.sky.200");
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.operator,
|
||||
.token.combinator {
|
||||
color: theme("colors.zinc.400");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai'
|
||||
|
||||
// Static routes with SEO priority for AI tools directory
|
||||
const staticRoutes = [
|
||||
{ path: '', changeFreq: 'daily' as const, priority: 1.0 },
|
||||
{ path: '/tools', changeFreq: 'daily' as const, priority: 0.95 }, // AI tools directory - high priority
|
||||
{ path: '/blog', changeFreq: 'weekly' as const, priority: 0.8 },
|
||||
{ path: '/contact', changeFreq: 'monthly' as const, priority: 0.5 },
|
||||
{ path: '/privacy', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
{ path: '/terms', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
{ path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
]
|
||||
|
||||
// Generate sitemap entries for both languages
|
||||
const locales = ['zh', 'en']
|
||||
const sitemapEntries: MetadataRoute.Sitemap = []
|
||||
|
||||
locales.forEach((locale) => {
|
||||
staticRoutes.forEach((route) => {
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}${route.path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route.changeFreq,
|
||||
priority: route.priority,
|
||||
alternates: {
|
||||
languages: {
|
||||
zh: `${baseUrl}/zh${route.path}`,
|
||||
en: `${baseUrl}/en${route.path}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return sitemapEntries
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user