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,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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user