feat: 四项体验修复(字号/品牌登录模板/第三方登录/免邮箱验证)

① 全局字号提升一档:body 基准 16.8px,14 个核心组件 99 处 text-xs/text-sm 放大
② 五站五套登录模板:FormShell 品牌分栏布局(左品牌色侧栏+右表单),
   每站独立 accent(近黑/橙红/纸墨棕/终端绿/紫),站点字标替换 Toolso.AI Logo;
   AuthLayout 简化为容器,布局职责下沉 FormShell
③ 第三方登录按钮:未配置 OAuth 凭据时显示 Google/GitHub 按钮,
   走 /api/auth/mock-social 一键创建演示 session(生产配置凭据后自动 403 切真实流程);
   按钮重设计为全宽方形(替换胶囊 Button)
④ 开发阶段免邮箱验证:better-auth databaseHooks 注册即 emailVerified=true,
   sendOnSignUp 仅生产开启;注册成功直接回跳原页面(跳过 check-email)

回归验证:字号 16.8px / 五站品牌色各异 / mock 登录闭环(投票→登录→回原页)/
注册免验证直接进站(emailVerified=t + 跳首页)全部 PASS
This commit is contained in:
AI Directory Platform
2026-09-09 06:02:16 +00:00
parent 3a2adbec76
commit 14af0aaa3a
54 changed files with 537 additions and 247 deletions
+4 -1
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
+4 -1
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
+11
View File
@@ -23,6 +23,7 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
// ==================================================================== // ====================================================================
const SITES = { const SITES = {
"ai-stack": { "ai-stack": {
accent: "222 47% 11%", // 近黑(专业)
port: 3001, port: 3001,
theme: "classic", theme: "classic",
brand: { brand: {
@@ -81,6 +82,7 @@ const SITES = {
}, },
}, },
launchai: { launchai: {
accent: "16 85% 55%", // 橙红(产品社区)
port: 3002, port: 3002,
theme: "product", theme: "product",
brand: { brand: {
@@ -137,6 +139,7 @@ const SITES = {
}, },
}, },
"the-ai-ledger": { "the-ai-ledger": {
accent: "36 70% 40%", // 纸墨棕(编辑部)
port: 3003, port: 3003,
theme: "editorial", theme: "editorial",
brand: { brand: {
@@ -194,6 +197,7 @@ const SITES = {
}, },
}, },
"devai-tools": { "devai-tools": {
accent: "150 60% 30%", // 终端绿(开发者)
port: 3004, port: 3004,
theme: "niche", theme: "niche",
brand: { brand: {
@@ -249,6 +253,7 @@ const SITES = {
}, },
}, },
findai: { findai: {
accent: "260 70% 52%", // 紫(极简搜索)
port: 3005, port: 3005,
theme: "minimal", theme: "minimal",
brand: { brand: {
@@ -684,6 +689,12 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>" RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:${site.port}" NEXT_PUBLIC_APP_URL="http://localhost:${site.port}"
NEXT_PUBLIC_APP_NAME="${site.brand.name}" NEXT_PUBLIC_APP_NAME="${site.brand.name}"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="${site.accent}"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="${site.brand.tagline}"
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
` `
); );
+4 -4
View File
@@ -70,7 +70,7 @@ export function ToolCard({
)} )}
> >
{typeof rank === "number" && ( {typeof rank === "number" && (
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow tabular-nums"> <span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-[0.85rem] font-bold text-primary-foreground shadow tabular-nums">
{rank} {rank}
</span> </span>
)} )}
@@ -85,7 +85,7 @@ export function ToolCard({
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" /> <ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground group-hover:text-primary"> <h3 className="truncate text-[0.95rem] font-semibold text-foreground group-hover:text-primary">
{name} {name}
</h3> </h3>
{pricing && ( {pricing && (
@@ -94,7 +94,7 @@ export function ToolCard({
</span> </span>
)} )}
{(tool.ratingAvg ?? 0) > 0 && ( {(tool.ratingAvg ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground">
<StarRating value={tool.ratingAvg ?? 0} /> <StarRating value={tool.ratingAvg ?? 0} />
<span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span> <span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span>
</span> </span>
@@ -103,7 +103,7 @@ export function ToolCard({
{tagline && ( {tagline && (
<p <p
className={cn( className={cn(
"mt-1 text-xs leading-relaxed text-muted-foreground", "mt-1 text-[0.85rem] leading-relaxed text-muted-foreground",
variant === "compact" ? "line-clamp-1" : "line-clamp-2" variant === "compact" ? "line-clamp-1" : "line-clamp-2"
)} )}
> >
@@ -109,6 +109,9 @@ export function LoginForm() {
isLoading={isLoading} isLoading={isLoading}
/> />
} }
brandName={process.env.NEXT_PUBLIC_APP_NAME}
brandTagline={process.env.NEXT_PUBLIC_AUTH_BRAND_TAGLINE}
accent={process.env.NEXT_PUBLIC_AUTH_ACCENT}
> >
<FormTextField <FormTextField
control={form.control} control={form.control}
@@ -58,8 +58,16 @@ export function SignupForm() {
console.error('发送验证邮件失败:', verificationError); console.error('发送验证邮件失败:', verificationError);
} }
// 跳转到邮箱验证提示页面,而不是直接登录 // 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
router.push(`/${locale}/check-email`); router.push(`/${locale}/check-email`);
} else {
const redirect =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect")
: null;
router.push(redirect || `/${locale}/`);
}
} catch (err) { } catch (err) {
setError(t('errors.signupFailed')); setError(t('errors.signupFailed'));
} finally { } finally {
@@ -67,12 +75,26 @@ export function SignupForm() {
} }
} }
async function handleGoogleSignIn() { async function handleGoogleSignIn(callbackURL?: string) {
try { try {
setIsLoading(true); setIsLoading(true);
await signIn.social({ await signIn.social({
provider: "google", provider: "google",
callbackURL: "/", callbackURL: callbackURL || "/",
});
} catch (err) {
setError(t('errors.googleSignupFailed'));
} finally {
setIsLoading(false);
}
}
async function handleGithubSignIn(callbackURL?: string) {
try {
setIsLoading(true);
await signIn.social({
provider: "github",
callbackURL: callbackURL || "/",
}); });
} catch (err) { } catch (err) {
setError(t('errors.googleSignupFailed')); setError(t('errors.googleSignupFailed'));
@@ -98,7 +120,16 @@ export function SignupForm() {
</Link> </Link>
</p> </p>
} }
socialSlot={<SocialAuthButtons onGoogleSignIn={handleGoogleSignIn} isLoading={isLoading} />} socialSlot={
<SocialAuthButtons
onGoogleSignIn={handleGoogleSignIn}
onGithubSignIn={handleGithubSignIn}
isLoading={isLoading}
/>
}
brandName={process.env.NEXT_PUBLIC_APP_NAME}
brandTagline={process.env.NEXT_PUBLIC_AUTH_BRAND_TAGLINE}
accent={process.env.NEXT_PUBLIC_AUTH_ACCENT}
> >
<FormTextField <FormTextField
control={form.control} control={form.control}
@@ -1,8 +1,15 @@
"use client"; "use client";
import { Button } from "@/components/button"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react"; import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
import { useTranslations } from 'next-intl';
interface SocialAuthButtonsProps {
onGoogleSignIn?: (callbackURL?: string) => Promise<void> | void;
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
isLoading?: boolean;
}
/** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */ /** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */
function getCallbackURL(): string { function getCallbackURL(): string {
@@ -10,15 +17,12 @@ function getCallbackURL(): string {
return new URLSearchParams(window.location.search).get("redirect") || "/"; return new URLSearchParams(window.location.search).get("redirect") || "/";
} }
interface SocialAuthButtonsProps {
onGoogleSignIn: (callbackURL?: string) => Promise<void> | void;
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
isLoading?: boolean;
}
/** /**
* # 组件 SocialAuthButtons * # 组件 SocialAuthButtons
* 说明:第三方登录按钮组(Google / GitHub)。 * 说明:第三方登录按钮组(Google / GitHub)。
* - 配置了真实 OAuth 凭据(NEXT_PUBLIC_AUTH_GOOGLE_ID / NEXT_PUBLIC_AUTH_GITHUB_ID)→ 走 better-auth OAuth 跳转。
* - 开发/演示环境未配置凭据 → 按钮依然显示,点击调用 /api/auth/mock-social 一键创建演示 session
* (生产配置凭据后 mock 端点自动 403,按钮自动走真实流程)。
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。 * 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
*/ */
export function SocialAuthButtons({ export function SocialAuthButtons({
@@ -26,41 +30,68 @@ export function SocialAuthButtons({
onGithubSignIn, onGithubSignIn,
isLoading, isLoading,
}: SocialAuthButtonsProps) { }: SocialAuthButtonsProps) {
const t = useTranslations('auth.social'); const t = useTranslations("auth.social");
const googleEnabled = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ENABLED !== "false"; const router = useRouter();
const githubEnabled = process.env.NEXT_PUBLIC_AUTH_GITHUB_ENABLED === "true"; const locale = useLocale();
const [mockLoading, setMockLoading] = useState<string | null>(null);
const [mockError, setMockError] = useState(false);
const showGoogle = googleEnabled && !!onGoogleSignIn; const realGoogle = !!process.env.NEXT_PUBLIC_AUTH_GOOGLE_ID;
const showGithub = githubEnabled && !!onGithubSignIn; const realGithub = !!process.env.NEXT_PUBLIC_AUTH_GITHUB_ID;
if (!showGoogle && !showGithub) return null; const mockSignIn = async (provider: "google" | "github") => {
setMockError(false);
setMockLoading(provider);
try {
const res = await fetch("/api/auth/mock-social", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
});
if (!res.ok) throw new Error("mock failed");
// session 已建立,回跳原页面
const target = getCallbackURL();
window.location.assign(`/${locale}${target === "/" ? "" : target}`);
} catch {
setMockLoading(null);
setMockError(true);
}
};
const loading = isLoading || mockLoading !== null;
const btnClass =
"flex w-full items-center justify-center gap-2.5 rounded-lg border border-border bg-background px-4 py-2.5 text-[0.95rem] font-medium text-foreground transition-colors hover:bg-secondary/60 disabled:opacity-50";
return ( return (
<div className={showGoogle && showGithub ? "flex flex-col gap-2" : undefined}> <div className="flex w-full flex-col gap-2.5">
{showGoogle && ( <button
<Button
onClick={() => onGoogleSignIn(getCallbackURL())}
className="w-full py-1.5"
variant="outline"
disabled={isLoading}
type="button" type="button"
onClick={() =>
realGoogle ? onGoogleSignIn?.(getCallbackURL()) : mockSignIn("google")
}
className={btnClass}
disabled={loading}
> >
<IconBrandGoogle className="h-5 w-5" /> <IconBrandGoogle className="h-5 w-5" />
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span> <span>{mockLoading === "google" ? t("loading") : t("googleSignIn")}</span>
</Button> </button>
)} <button
{showGithub && (
<Button
onClick={() => onGithubSignIn(getCallbackURL())}
className="w-full py-1.5"
variant="outline"
disabled={isLoading}
type="button" type="button"
onClick={() =>
realGithub ? onGithubSignIn?.(getCallbackURL()) : mockSignIn("github")
}
className={btnClass}
disabled={loading}
> >
<IconBrandGithub className="h-5 w-5" /> <IconBrandGithub className="h-5 w-5" />
<span className="text-sm font-semibold leading-6">{t('githubSignIn')}</span> <span>{mockLoading === "github" ? t("loading") : t("githubSignIn")}</span>
</Button> </button>
{mockError && (
<p className="text-center text-xs text-red-500">{t("mockFailed")}</p>
)} )}
</div> </div>
); );
} }
export { getCallbackURL };
@@ -31,7 +31,7 @@ export function ClaimToolButton({
if (claimed) { if (claimed) {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-xs font-medium text-primary"> <span className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-[0.85rem] font-medium text-primary">
<BadgeCheck className="h-3.5 w-3.5" /> <BadgeCheck className="h-3.5 w-3.5" />
{t("verifiedOwner")} {t("verifiedOwner")}
</span> </span>
@@ -68,7 +68,7 @@ export function ClaimToolButton({
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground" className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
> >
<Flag className="h-3.5 w-3.5" /> <Flag className="h-3.5 w-3.5" />
{t("claimThisTool")} {t("claimThisTool")}
@@ -77,11 +77,11 @@ export function ClaimToolButton({
} }
const inputClass = const inputClass =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"; "w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
return ( return (
<div className="space-y-3 rounded-xl border border-border p-4"> <div className="space-y-3 rounded-xl border border-border p-4">
<p className="text-sm font-medium text-foreground">{t("formTitle")}</p> <p className="text-[0.95rem] font-medium text-foreground">{t("formTitle")}</p>
<input <input
className={inputClass} className={inputClass}
placeholder={t("rolePlaceholder")} placeholder={t("rolePlaceholder")}
@@ -40,7 +40,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
if (claims.length === 0) { if (claims.length === 0) {
return ( return (
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground"> <p className="rounded-xl border border-dashed border-border p-12 text-center text-[0.95rem] text-muted-foreground">
No claims in the queue. No claims in the queue.
</p> </p>
); );
@@ -54,24 +54,24 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">{claimRow.userName}</span> <span className="font-semibold text-foreground">{claimRow.userName}</span>
<span className="text-xs text-muted-foreground">claims</span> <span className="text-[0.85rem] text-muted-foreground">claims</span>
<span className="font-semibold text-foreground">{claimRow.toolName}</span> <span className="font-semibold text-foreground">{claimRow.toolName}</span>
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground"> <span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{claimRow.status} {claimRow.status}
</span> </span>
</div> </div>
{claimRow.role && ( {claimRow.role && (
<p className="mt-1 text-xs text-muted-foreground">Role: {claimRow.role}</p> <p className="mt-1 text-[0.85rem] text-muted-foreground">Role: {claimRow.role}</p>
)} )}
{claimRow.message && ( {claimRow.message && (
<p className="mt-1.5 text-sm text-muted-foreground">{claimRow.message}</p> <p className="mt-1.5 text-[0.95rem] text-muted-foreground">{claimRow.message}</p>
)} )}
{claimRow.proofUrl && ( {claimRow.proofUrl && (
<a <a
href={claimRow.proofUrl} href={claimRow.proofUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-primary hover:underline" className="mt-1 inline-block text-[0.85rem] text-primary hover:underline"
> >
Proof: {claimRow.proofUrl} Proof: {claimRow.proofUrl}
</a> </a>
@@ -84,7 +84,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => review(claimRow.id, "approved")} onClick={() => review(claimRow.id, "approved")}
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-[0.85rem] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
Approve Approve
@@ -93,7 +93,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => review(claimRow.id, "rejected")} onClick={() => review(claimRow.id, "rejected")}
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
Reject Reject
@@ -42,7 +42,7 @@ interface DiscoveryManagerProps {
type Tab = "useCases" | "audiences" | "collections"; type Tab = "useCases" | "audiences" | "collections";
const inputClass = const inputClass =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"; "w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
/** /**
* # 组件 DiscoveryManager * # 组件 DiscoveryManager
@@ -147,7 +147,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={() => setTab(item.id)} onClick={() => setTab(item.id)}
className={cn( className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors", "rounded-lg px-4 py-2 text-[0.95rem] font-medium transition-colors",
tab === item.id tab === item.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -177,7 +177,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitUseCase} onClick={submitUseCase}
disabled={isPending} disabled={isPending}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Create
@@ -190,8 +190,8 @@ export function DiscoveryManager({
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{uc.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{uc.name}</span>
<span className="ml-2 text-xs text-muted-foreground">/{uc.slug}</span> <span className="ml-2 text-[0.85rem] text-muted-foreground">/{uc.slug}</span>
</div> </div>
<button <button
type="button" type="button"
@@ -227,7 +227,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitAudience} onClick={submitAudience}
disabled={isPending} disabled={isPending}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Create
@@ -240,8 +240,8 @@ export function DiscoveryManager({
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{aud.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{aud.name}</span>
<span className="ml-2 text-xs text-muted-foreground">/{aud.slug}</span> <span className="ml-2 text-[0.85rem] text-muted-foreground">/{aud.slug}</span>
</div> </div>
<button <button
type="button" type="button"
@@ -284,7 +284,7 @@ export function DiscoveryManager({
type="button" type="button"
onClick={submitCollection} onClick={submitCollection}
disabled={isPending} disabled={isPending}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-[0.95rem] font-semibold text-primary-foreground disabled:opacity-50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create collection Create collection
@@ -297,8 +297,8 @@ export function DiscoveryManager({
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5" className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
> >
<div> <div>
<span className="text-sm font-medium text-foreground">{col.name}</span> <span className="text-[0.95rem] font-medium text-foreground">{col.name}</span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-[0.85rem] text-muted-foreground">
/best/{col.slug} · {col.toolCount} tools /best/{col.slug} · {col.toolCount} tools
</span> </span>
</div> </div>
@@ -23,6 +23,12 @@ interface FormShellProps<TFieldValues extends FieldValues> {
className?: string; className?: string;
headerSlot?: React.ReactNode; headerSlot?: React.ReactNode;
socialSlot?: React.ReactNode; socialSlot?: React.ReactNode;
/** 站点品牌名(默认取环境变量,五站登录页各不相同) */
brandName?: string;
/** 品牌侧栏标语 */
brandTagline?: string;
/** 品牌强调色 HSL 三元组(如 "16 85% 55%"),传入即启用分栏品牌模板 */
accent?: string;
} }
export function FormShell<TFieldValues extends FieldValues>({ export function FormShell<TFieldValues extends FieldValues>({
@@ -39,9 +45,92 @@ export function FormShell<TFieldValues extends FieldValues>({
className, className,
headerSlot, headerSlot,
socialSlot, socialSlot,
brandName,
brandTagline,
accent,
}: FormShellProps<TFieldValues>) { }: FormShellProps<TFieldValues>) {
const t = useTranslations('auth.social'); const t = useTranslations('auth.social');
const headerContent = headerSlot !== undefined ? headerSlot : <Logo />; const brand = brandName || process.env.NEXT_PUBLIC_APP_NAME || 'AI Directory';
// 品牌模式用站点品牌字标(不传 headerSlot 时),非品牌模式保留原 Logo
const headerContent =
headerSlot !== undefined
? headerSlot
: accent
? <span className="inline-flex items-center gap-2 text-lg font-bold text-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-lg text-white" style={{ backgroundColor: `hsl(${accent})` }}>
{brand.slice(0, 1)}
</span>
{brand}
</span>
: <Logo />;
// 品牌模板:传入 accent 时渲染分栏布局(左侧品牌区 / 右侧表单),每站视觉不同
const formColumn = (
<div className="mx-auto w-full max-w-md">
<div>
<div className="flex">{headerContent}</div>
<h2 className="mt-8 text-3xl font-bold leading-9 tracking-tight text-foreground">
{title}
</h2>
{description ? (
<p className="mt-2 text-[0.95rem] leading-6 text-muted-foreground">{description}</p>
) : null}
</div>
<div className="mt-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{socialSlot}
{socialSlot ? (
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">{t('orContinueWith')}</span>
<span className="h-px flex-1 bg-border" />
</div>
) : null}
{children}
{error ? (
<div className="rounded-md bg-destructive/10 p-3 text-[0.95rem] text-red-600">{error}</div>
) : null}
<Button className="w-full" disabled={isLoading} type="submit">
{isLoading ? submitLoadingText ?? submitText : submitText}
</Button>
</form>
{footer}
</div>
</div>
);
if (accent) {
return (
<Form {...form}>
{/* 品牌分栏模板:col-span 全宽(覆盖 AuthLayout 的 2 列网格),左品牌右表单 */}
<div className={cn("col-span-full grid min-h-screen w-full lg:grid-cols-2", className)}>
<div
className="relative hidden flex-col justify-between overflow-hidden p-12 lg:flex"
style={{ backgroundColor: `hsl(${accent})` }}
>
<div className="flex items-center gap-2 text-xl font-bold text-white">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">
{brand.slice(0, 1)}
</span>
{brand}
</div>
<div>
<p className="max-w-sm text-2xl font-semibold leading-snug text-white">
{brandTagline || `Welcome back to ${brand}.`}
</p>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-white/70">
Vote, review and save the AI tools you love — join the community.
</p>
</div>
<p className="text-xs text-white/50">© {new Date().getFullYear()} {brand}</p>
</div>
<div className="flex items-center justify-center px-4 py-12 sm:px-6 lg:px-20 xl:px-24">
{formColumn}
</div>
</div>
</Form>
);
}
return ( return (
<Form {...form}> <Form {...form}>
@@ -51,53 +140,7 @@ export function FormShell<TFieldValues extends FieldValues>({
className className
)} )}
> >
<div className="mx-auto w-full max-w-md"> {formColumn}
<div>
<div className="flex">{headerContent}</div>
<h2 className="mt-8 text-2xl font-bold leading-9 tracking-tight text-foreground">
{title}
</h2>
{description ? (
<p className="mt-2 text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
<div className="mt-10">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{children}
{error ? (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-red-600">
{error}
</div>
) : null}
<Button className="w-full" disabled={isLoading} type="submit">
{isLoading ? submitLoadingText ?? submitText : submitText}
</Button>
{footer}
</form>
</div>
{socialSlot ? (
<div className="mt-10">
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm font-medium leading-6">
<span className="bg-background px-6 text-muted-foreground">
{t('or')}
</span>
</div>
</div>
<div className="mt-6 space-y-3">{socialSlot}</div>
</div>
) : null}
</div>
</div> </div>
</Form> </Form>
); );
@@ -144,7 +144,7 @@ export function ReviewsSection({
)} )}
{reviews.length === 0 && !formOpen ? ( {reviews.length === 0 && !formOpen ? (
<p className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> <p className="rounded-xl border border-dashed border-border p-8 text-center text-[0.95rem] text-muted-foreground">
{t("empty")} {t("empty")}
</p> </p>
) : ( ) : (
@@ -156,11 +156,11 @@ export function ReviewsSection({
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-foreground"> <div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-[0.95rem] font-semibold text-foreground">
{review.authorName.slice(0, 1).toUpperCase()} {review.authorName.slice(0, 1).toUpperCase()}
</div> </div>
<div> <div>
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground"> <div className="flex items-center gap-1.5 text-[0.95rem] font-medium text-foreground">
{review.authorName} {review.authorName}
{review.authorEmailVerified && ( {review.authorEmailVerified && (
<BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} /> <BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} />
@@ -168,7 +168,7 @@ export function ReviewsSection({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StarRating value={review.rating} /> <StarRating value={review.rating} />
<span className="text-xs text-muted-foreground"> <span className="text-[0.85rem] text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString( {new Date(review.createdAt).toLocaleDateString(
locale === "zh" ? "zh-CN" : "en-US" locale === "zh" ? "zh-CN" : "en-US"
)} )}
@@ -181,8 +181,8 @@ export function ReviewsSection({
onClick={() => onHelpful(review.id)} onClick={() => onHelpful(review.id)}
className={ className={
helpfulIds.has(review.id) helpfulIds.has(review.id)
? "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs text-primary" ? "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-[0.85rem] text-primary"
: "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" : "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[0.85rem] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
} }
> >
<ThumbsUp className="h-3.5 w-3.5" /> <ThumbsUp className="h-3.5 w-3.5" />
@@ -191,24 +191,24 @@ export function ReviewsSection({
</div> </div>
{review.title && ( {review.title && (
<h3 className="text-sm font-semibold text-foreground">{review.title}</h3> <h3 className="text-[0.95rem] font-semibold text-foreground">{review.title}</h3>
)} )}
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap"> <p className="text-[0.95rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{review.content} {review.content}
</p> </p>
{(review.pros || review.cons) && ( {(review.pros || review.cons) && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{review.pros && ( {review.pros && (
<div className="rounded-lg bg-emerald-500/5 p-3 text-sm text-foreground"> <div className="rounded-lg bg-emerald-500/5 p-3 text-[0.95rem] text-foreground">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> <span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t("pros")} {t("pros")}
</span> </span>
{review.pros} {review.pros}
</div> </div>
)} )}
{review.cons && ( {review.cons && (
<div className="rounded-lg bg-red-500/5 p-3 text-sm text-foreground"> <div className="rounded-lg bg-red-500/5 p-3 text-[0.95rem] text-foreground">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400"> <span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
{t("cons")} {t("cons")}
</span> </span>
{review.cons} {review.cons}
@@ -286,7 +286,7 @@ function ReviewForm({
}; };
const inputClass = const inputClass =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"; "w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
return ( return (
<form <form
@@ -294,7 +294,7 @@ function ReviewForm({
className="space-y-4 rounded-xl border border-border p-5" className="space-y-4 rounded-xl border border-border p-5"
> >
<div> <div>
<span className="mb-1.5 block text-sm font-medium text-foreground"> <span className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("yourRating")} {t("yourRating")}
</span> </span>
<StarRating value={rating} onChange={setRating} size="lg" /> <StarRating value={rating} onChange={setRating} size="lg" />
@@ -337,7 +337,7 @@ function ReviewForm({
maxLength={200} maxLength={200}
onChange={(e) => setUseCase(e.target.value)} onChange={(e) => setUseCase(e.target.value)}
/> />
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-[0.95rem] text-red-500">{error}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={submitting}>
{submitting ? t("saving") : existing ? t("update") : t("submit")} {submitting ? t("saving") : existing ? t("update") : t("submit")}
@@ -349,7 +349,7 @@ function ReviewForm({
<button <button
type="button" type="button"
onClick={onDeleted} onClick={onDeleted}
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-red-500" className="ml-auto inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground hover:text-red-500"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{t("delete")} {t("delete")}
@@ -382,7 +382,7 @@ export function RatingSummary({
</span> </span>
<div> <div>
<StarRating value={avg} size="md" /> <StarRating value={avg} size="md" />
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-[0.85rem] text-muted-foreground">
{t("basedOn", { count: total })} {t("basedOn", { count: total })}
</p> </p>
</div> </div>
@@ -392,7 +392,7 @@ export function RatingSummary({
const count = distribution[star] ?? 0; const count = distribution[star] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0; const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return ( return (
<div key={star} className="flex items-center gap-2 text-xs"> <div key={star} className="flex items-center gap-2 text-[0.85rem]">
<span className="w-6 text-muted-foreground tabular-nums">{star}</span> <span className="w-6 text-muted-foreground tabular-nums">{star}</span>
<Star className="h-3 w-3 fill-amber-400 text-amber-400" /> <Star className="h-3 w-3 fill-amber-400 text-amber-400" />
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary"> <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
@@ -62,7 +62,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
if (submissions.length === 0) { if (submissions.length === 0) {
return ( return (
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground"> <p className="rounded-xl border border-dashed border-border p-12 text-center text-[0.95rem] text-muted-foreground">
No submissions in the queue. No submissions in the queue.
</p> </p>
); );
@@ -84,20 +84,20 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
href={sub.websiteUrl} href={sub.websiteUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary" className="mt-0.5 inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground hover:text-primary"
> >
{sub.domain || sub.websiteUrl} {sub.domain || sub.websiteUrl}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
{sub.tagline && ( {sub.tagline && (
<p className="mt-1.5 text-sm text-muted-foreground">{sub.tagline}</p> <p className="mt-1.5 text-[0.95rem] text-muted-foreground">{sub.tagline}</p>
)} )}
{sub.description && ( {sub.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground"> <p className="mt-1 line-clamp-2 text-[0.85rem] text-muted-foreground">
{sub.description} {sub.description}
</p> </p>
)} )}
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-[0.85rem] text-muted-foreground">
{sub.founderName || "Unknown founder"} {sub.founderName || "Unknown founder"}
{sub.contactEmail ? ` · ${sub.contactEmail}` : ""} {sub.contactEmail ? ` · ${sub.contactEmail}` : ""}
{sub.pricing ? ` · ${sub.pricing}` : ""} {sub.pricing ? ` · ${sub.pricing}` : ""}
@@ -110,7 +110,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => approve(sub.id)} onClick={() => approve(sub.id)}
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-[0.85rem] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
Approve Approve
@@ -119,7 +119,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
type="button" type="button"
disabled={isPending} disabled={isPending}
onClick={() => setRejectingId(sub.id)} onClick={() => setRejectingId(sub.id)}
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50" className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-[0.85rem] font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
Reject Reject
@@ -135,21 +135,21 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
value={rejectNote} value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)} onChange={(e) => setRejectNote(e.target.value)}
placeholder="Reason for rejection (shown to submitter)..." placeholder="Reason for rejection (shown to submitter)..."
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] focus:outline-none focus:ring-2 focus:ring-primary/40"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => reject(sub.id)} onClick={() => reject(sub.id)}
disabled={isPending} disabled={isPending}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50" className="rounded-lg bg-red-600 px-3 py-1.5 text-[0.85rem] font-semibold text-white hover:bg-red-700 disabled:opacity-50"
> >
Confirm reject Confirm reject
</button> </button>
<button <button
type="button" type="button"
onClick={() => setRejectingId(null)} onClick={() => setRejectingId(null)}
className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground" className="rounded-lg border border-border px-3 py-1.5 text-[0.85rem] font-medium text-muted-foreground"
> >
Cancel Cancel
</button> </button>
@@ -92,12 +92,12 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
}; };
const inputClass = const inputClass =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"; "w-full rounded-lg border border-border bg-background px-3 py-2 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{duplicates.length > 0 && ( {duplicates.length > 0 && (
<div className="rounded-lg border border-amber-300/60 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300"> <div className="rounded-lg border border-amber-300/60 bg-amber-50 p-3 text-[0.95rem] text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300">
<div className="flex items-center gap-2 font-medium"> <div className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{t("duplicateFound")} {t("duplicateFound")}
@@ -107,7 +107,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<button <button
key={dup.slug} key={dup.slug}
type="button" type="button"
className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium underline-offset-2 hover:underline dark:bg-amber-900/50" className="rounded-full bg-amber-100 px-2.5 py-1 text-[0.85rem] font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)} onClick={() => router.push(`/${locale}/tools/${dup.slug}`)}
> >
{dup.name} {dup.name}
@@ -119,7 +119,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("name")} * {t("name")} *
</label> </label>
<input <input
@@ -134,7 +134,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("websiteUrl")} * {t("websiteUrl")} *
</label> </label>
<input <input
@@ -150,7 +150,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("tagline")} {t("tagline")}
</label> </label>
<input <input
@@ -163,7 +163,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("description")} * {t("description")} *
</label> </label>
<textarea <textarea
@@ -180,7 +180,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("category")} {t("category")}
</label> </label>
<select <select
@@ -197,7 +197,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("pricingLabel")} {t("pricingLabel")}
</label> </label>
<select <select
@@ -218,7 +218,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("founderName")} {t("founderName")}
</label> </label>
<input <input
@@ -229,7 +229,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("contactEmail")} {t("contactEmail")}
</label> </label>
<input <input
@@ -242,7 +242,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("socialLinks")} {t("socialLinks")}
</label> </label>
<input <input
@@ -255,7 +255,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground"> <label className="mb-1.5 block text-[0.95rem] font-medium text-foreground">
{t("note")} {t("note")}
</label> </label>
<textarea <textarea
@@ -268,14 +268,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-[0.95rem] text-red-500">{error}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="submit" disabled={submitting || checking}> <Button type="submit" disabled={submitting || checking}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
{submitting ? t("submitting") : t("submit")} {submitting ? t("submitting") : t("submit")}
</Button> </Button>
<span className="text-xs text-muted-foreground">{t("reviewNote")}</span> <span className="text-[0.85rem] text-muted-foreground">{t("reviewNote")}</span>
</div> </div>
</form> </form>
); );
@@ -141,7 +141,7 @@ export function FavoriteButton({
type="button" type="button"
onClick={() => ctx.toggle(toolId)} onClick={() => ctx.toggle(toolId)}
className={cn( className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors", "flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-[0.95rem] font-medium transition-colors",
favorited favorited
? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:text-red-400" ? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:text-red-400"
: "border-border text-foreground hover:bg-hover" : "border-border text-foreground hover:bg-hover"
@@ -198,7 +198,7 @@ export function ToolsList({
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</button> </button>
</form> </form>
<div className="text-sm text-muted-foreground whitespace-nowrap"> <div className="text-[0.95rem] text-muted-foreground whitespace-nowrap">
{translations.foundTools} {translations.foundTools}
</div> </div>
</div> </div>
@@ -207,7 +207,7 @@ export function ToolsList({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => handleCategoryChange(null)} onClick={() => handleCategoryChange(null)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
!currentCategory !currentCategory
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -219,7 +219,7 @@ export function ToolsList({
<button <button
key={category.id} key={category.id}
onClick={() => handleCategoryChange(category.slug)} onClick={() => handleCategoryChange(category.slug)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
currentCategory === category.slug currentCategory === category.slug
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -235,7 +235,7 @@ export function ToolsList({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => handlePricingChange(null)} onClick={() => handlePricingChange(null)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
!currentPricing !currentPricing
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -247,7 +247,7 @@ export function ToolsList({
<button <button
key={pricing} key={pricing}
onClick={() => handlePricingChange(pricing)} onClick={() => handlePricingChange(pricing)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
currentPricing === pricing currentPricing === pricing
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -258,7 +258,7 @@ export function ToolsList({
))} ))}
</div> </div>
{/* 第四行:排序 */} {/* 第四行:排序 */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-[0.85rem]">
<span className="text-muted-foreground">Sort</span> <span className="text-muted-foreground">Sort</span>
{(["newest", "trending"] as const).map((sort) => ( {(["newest", "trending"] as const).map((sort) => (
<button <button
@@ -282,7 +282,7 @@ export function ToolsList({
<button <button
key={uc.slug} key={uc.slug}
onClick={() => handleUseCaseChange(uc.slug)} onClick={() => handleUseCaseChange(uc.slug)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-full text-[0.85rem] font-medium transition-colors ${
currentUseCase === uc.slug currentUseCase === uc.slug
? "bg-foreground text-background" ? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover" : "bg-secondary text-muted-foreground hover:bg-hover"
@@ -310,7 +310,7 @@ export function ToolsList({
{getName(tool)} {getName(tool)}
</h3> </h3>
{tool.domain && ( {tool.domain && (
<p className="text-sm text-muted-foreground truncate"> <p className="text-[0.95rem] text-muted-foreground truncate">
{tool.domain} {tool.domain}
</p> </p>
)} )}
@@ -322,14 +322,14 @@ export function ToolsList({
</div> </div>
{getDescription(tool) && ( {getDescription(tool) && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2"> <p className="mt-3 text-[0.95rem] text-muted-foreground line-clamp-2">
{getDescription(tool)} {getDescription(tool)}
</p> </p>
)} )}
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
{tool.websiteUrl ? ( {tool.websiteUrl ? (
<div className="flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground"> <div className="flex items-center gap-1 text-[0.95rem] text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
<span>{translations.visitWebsite}</span> <span>{translations.visitWebsite}</span>
</div> </div>
@@ -337,7 +337,7 @@ export function ToolsList({
<span /> <span />
)} )}
{tool.pricing && pricingLabels[tool.pricing] && ( {tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground"> <span className="rounded-full bg-secondary px-2 py-0.5 text-[0.85rem] text-muted-foreground">
{pricingLabels[tool.pricing]} {pricingLabels[tool.pricing]}
</span> </span>
)} )}
@@ -354,7 +354,7 @@ export function ToolsList({
</p> </p>
<button <button
onClick={() => router.push(`/${locale}/tools`)} onClick={() => router.push(`/${locale}/tools`)}
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
> >
{translations.clearFilters} {translations.clearFilters}
</button> </button>
@@ -391,7 +391,7 @@ export function ToolsList({
)} )}
<button <button
onClick={() => handlePageChange(page)} onClick={() => handlePageChange(page)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ className={`px-4 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${
currentPage === page currentPage === page
? "bg-foreground text-background" ? "bg-foreground text-background"
: "hover:bg-hover" : "hover:bg-hover"
@@ -163,7 +163,7 @@ export function VoteButton({
if (!ctx) { if (!ctx) {
// 未包裹 Provider 时渲染静态计数(服务端渲染回退) // 未包裹 Provider 时渲染静态计数(服务端渲染回退)
return ( return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 text-[0.85rem] text-muted-foreground">
<ChevronUp className="h-3.5 w-3.5" /> <ChevronUp className="h-3.5 w-3.5" />
{initialCount ?? 0} {initialCount ?? 0}
</span> </span>
@@ -193,7 +193,7 @@ export function VoteButton({
)} )}
> >
<ChevronUp className="h-4 w-4 stroke-[2]" /> <ChevronUp className="h-4 w-4 stroke-[2]" />
<span className="text-sm font-semibold tabular-nums">{count}</span> <span className="text-[0.95rem] font-semibold tabular-nums">{count}</span>
</button> </button>
); );
} }
@@ -205,7 +205,7 @@ export function VoteButton({
onClick={() => ctx.toggle(toolId)} onClick={() => ctx.toggle(toolId)}
aria-pressed={voted} aria-pressed={voted}
className={cn( className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-all active:scale-[0.98]", "flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-[0.95rem] font-medium transition-all active:scale-[0.98]",
voted voted
? "border-primary/60 bg-primary/10 text-primary" ? "border-primary/60 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-hover" : "border-border text-foreground hover:bg-hover"
@@ -227,7 +227,7 @@ export function VoteButton({
ctx.toggle(toolId); ctx.toggle(toolId);
}} }}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs transition-colors", "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[0.85rem] transition-colors",
voted voted
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground" : "text-muted-foreground hover:bg-secondary hover:text-foreground"
+5 -5
View File
@@ -24,9 +24,9 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground"> <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-sm font-bold text-foreground">{brand.name}</span> <span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span>
</LocaleLink> </LocaleLink>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground"> <p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground">
{brand.tagline} {brand.tagline}
</p> </p>
<div className="mt-4 max-w-xs"> <div className="mt-4 max-w-xs">
@@ -35,7 +35,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
</div> </div>
{groups.map((group) => ( {groups.map((group) => (
<div key={group.title}> <div key={group.title}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground"> <h3 className="text-[0.85rem] font-semibold uppercase tracking-wider text-foreground">
{group.title} {group.title}
</h3> </h3>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
@@ -43,7 +43,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
<li key={link.href + link.label}> <li key={link.href + link.label}>
<LocaleLink <LocaleLink
href={link.href} href={link.href}
className="text-xs text-muted-foreground transition-colors hover:text-foreground" className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
> >
{link.label} {link.label}
</LocaleLink> </LocaleLink>
@@ -53,7 +53,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
</div> </div>
))} ))}
</div> </div>
<div className="mt-10 border-t border-border pt-6 text-center text-xs text-muted-foreground"> <div className="mt-10 border-t border-border pt-6 text-center text-[0.85rem] text-muted-foreground">
© {new Date().getFullYear()} {brand.name}. All rights reserved. © {new Date().getFullYear()} {brand.name}. All rights reserved.
</div> </div>
</div> </div>
+5 -5
View File
@@ -66,7 +66,7 @@ export function ThemeHeader({
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground"> <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-sm font-bold tracking-tight text-foreground"> <span className="text-[0.95rem] font-bold tracking-tight text-foreground">
{brand.name} {brand.name}
</span> </span>
</LocaleLink> </LocaleLink>
@@ -78,7 +78,7 @@ export function ThemeHeader({
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"rounded-lg px-3 py-1.5 text-sm transition-colors", "rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href) isActive(item.href)
? "bg-secondary font-medium text-foreground" ? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
@@ -99,7 +99,7 @@ export function ThemeHeader({
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-xs text-foreground placeholder:text-muted-foreground focus:w-60 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all" className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-[0.85rem] text-foreground placeholder:text-muted-foreground focus:w-60 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
aria-label={searchPlaceholder} aria-label={searchPlaceholder}
/> />
</div> </div>
@@ -133,7 +133,7 @@ export function ThemeHeader({
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"block rounded-lg px-3 py-2.5 text-sm", "block rounded-lg px-3 py-2.5 text-[0.95rem]",
isActive(item.href) isActive(item.href)
? "bg-secondary font-medium text-foreground" ? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
@@ -151,7 +151,7 @@ export function ThemeHeader({
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="h-10 w-full rounded-lg border border-border bg-secondary/50 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="h-10 w-full rounded-lg border border-border bg-secondary/50 pl-9 pr-3 text-[0.95rem] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
aria-label={searchPlaceholder} aria-label={searchPlaceholder}
/> />
</div> </div>
+4
View File
@@ -30,6 +30,10 @@ export interface SiteConfig {
port: number; port: number;
/** 站点 SEO 文案覆盖(en/zh) */ /** 站点 SEO 文案覆盖(en/zh) */
seo?: Record<string, { title: string; description: string }>; seo?: Record<string, { title: string; description: string }>;
/** 品牌强调色 HSL 三元组(登录页分栏模板) */
accent?: string;
/** 品牌侧栏标语(登录页) */
brandTagline?: string;
/** 主题级 CSS 变量覆盖(HSL 三元组,可选) */ /** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
cssVars?: Record<string, string>; cssVars?: Record<string, string>;
} }
+6 -33
View File
@@ -1,43 +1,16 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Image from "next/image";
import { useId } from "react";
import { motion } from "framer-motion";
import { HorizontalGradient } from "@/components/horizontal-gradient";
import { FeaturedTestimonials } from "@/components/featured-testimonials";
import { useTranslations } from 'next-intl';
/**
* # 组件 AuthLayout
* 说明:认证页容器。布局职责已下沉到 FormShell(品牌分栏/居中两套模板),
* 这里只提供页面底色,避免双层 grid 叠加导致的多列错位。
*/
export function AuthLayout({ children }: { children: React.ReactNode }) { export function AuthLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations('contact.testimonial');
return ( return (
<> <div className={cn("w-full min-h-screen bg-background")}>
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2">
{children} {children}
<div className="relative w-full z-20 hidden md:flex border-l border-border overflow-hidden bg-muted 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('title')}
</p>
<p
className={cn(
"font-normal text-base text-center text-muted-foreground mt-8"
)}
>
{t('description')}
</p>
</div> </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>
</>
); );
} }
+27
View File
@@ -22,6 +22,19 @@ export const auth = betterAuth({
enabled: true, enabled: true,
}, },
// 邮箱验证:生产发送真实邮件;开发/演示(默认)跳过发送并自动视为已验证
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
if (process.env.DEV_SKIP_EMAIL_VERIFY === "false") {
const { sendVerificationEmail } = await import("./email");
const token = new URL(url).searchParams.get("token") || "";
await sendVerificationEmail(user.email, token);
}
},
autoSignInAfterVerification: true,
sendOnSignUp: process.env.DEV_SKIP_EMAIL_VERIFY === "false",
},
socialProviders: { socialProviders: {
// Google(配置 env 后自动启用) // Google(配置 env 后自动启用)
...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET ...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
@@ -44,6 +57,20 @@ export const auth = betterAuth({
}, },
trustedOrigins, trustedOrigins,
// 开发模式:注册即 emailVerified(生产 DEV_SKIP_EMAIL_VERIFY=false 走真实验证流)
databaseHooks: {
user: {
create: {
before: async (userRow) => {
if (process.env.DEV_SKIP_EMAIL_VERIFY !== "false") {
return { data: { ...userRow, emailVerified: true } };
}
return { data: userRow };
},
},
},
},
}); });
export { hashPassword } from "better-auth/crypto"; export { hashPassword } from "better-auth/crypto";
+3 -1
View File
@@ -36,7 +36,9 @@ export default async function LocaleLayout({
<body <body
className={cn( className={cn(
GeistSans.className, GeistSans.className,
"bg-background text-foreground antialiased min-h-screen w-full" // 全局字号基准 16px(shadcn 默认 14px 偏小,整体提升一档)
"bg-background text-foreground antialiased min-h-screen w-full text-[1.05rem]",
"[&_h1]:text-4xl [&_h2]:text-2xl [&_h3]:text-lg"
)} )}
suppressHydrationWarning suppressHydrationWarning
> >
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { user, account } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { hashPassword } from "better-auth/crypto";
/**
* POST /api/auth/mock-social { provider: "google" | "github" }
* 说明:开发/演示环境的第三方登录 Mock。
* 未配置真实 OAuth 凭据时,前端显示第三方按钮,点击走此端点:
* 直接创建(或复用)对应 provider 的演示用户并建立 session —— 一键登录。
* 生产环境配置了 AUTH_GOOGLE_ID / AUTH_GITHUB_ID 后此端点自动禁用(403)。
*/
export async function POST(req: NextRequest) {
// 双保险:配置了真实凭据时拒绝 mock
if (process.env.AUTH_GOOGLE_ID || process.env.AUTH_GITHUB_ID) {
return NextResponse.json({ error: "MOCK_DISABLED" }, { status: 403 });
}
const { provider } = await req.json().catch(() => ({ provider: null }));
if (provider !== "google" && provider !== "github") {
return NextResponse.json({ error: "INVALID_PROVIDER" }, { status: 400 });
}
const email = `${provider}-user@demo.dev`;
const name = provider === "google" ? "Google Demo User" : "GitHub Demo User";
// 复用或创建演示用户
let [u] = await db.select().from(user).where(eq(user.email, email)).limit(1);
if (!u) {
[u] = await db
.insert(user)
.values({
id: `user_mock_${provider}`,
name,
email,
emailVerified: true,
role: "user",
})
.returning();
}
// 确保 credential 密码存在(一键登录用)
await db
.insert(account)
.values({
id: `acc_mock_${provider}`,
accountId: u.id,
providerId: "credential",
userId: u.id,
password: await hashPassword("password123"),
})
.onConflictDoNothing();
// 建立 session
const session = await auth.api.signInEmail({
body: { email, password: "password123" },
headers: await headers(),
});
return NextResponse.json({ ok: true, user: session?.user ?? null });
}
+6
View File
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="AI Stack <noreply@localhost>" RESEND_FROM_EMAIL="AI Stack <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3001" NEXT_PUBLIC_APP_URL="http://localhost:3001"
NEXT_PUBLIC_APP_NAME="AI Stack" NEXT_PUBLIC_APP_NAME="AI Stack"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="222 47% 11%"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="The professional AI tools directory — ranked by the community."
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,8 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters"
}, },
"seo": { "seo": {
"home": { "home": {
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,8 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -0,0 +1,2 @@
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
@@ -0,0 +1,2 @@
// 自动生成:api/auth/mock-social/route.ts 的薄路由
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
+1
View File
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
// AI Stack —— 独立站点的全部品牌/导航/页脚配置 // AI Stack —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
"accent": "222 47% 11%",
"port": 3001, "port": 3001,
"brand": { "brand": {
"name": "AI Stack", "name": "AI Stack",
+6
View File
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>" RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3004" NEXT_PUBLIC_APP_URL="http://localhost:3004"
NEXT_PUBLIC_APP_NAME="DevAI.tools" NEXT_PUBLIC_APP_NAME="DevAI.tools"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="150 60% 30%"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="AI tools for developers — curated, tested, ranked by the community."
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,8 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters"
}, },
"seo": { "seo": {
"home": { "home": {
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,8 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -0,0 +1,2 @@
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
@@ -0,0 +1,2 @@
// 自动生成:api/auth/mock-social/route.ts 的薄路由
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
+1
View File
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
// DevAI.tools —— 独立站点的全部品牌/导航/页脚配置 // DevAI.tools —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
"accent": "150 60% 30%",
"port": 3004, "port": 3004,
"brand": { "brand": {
"name": "DevAI.tools", "name": "DevAI.tools",
+6
View File
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="findai <noreply@localhost>" RESEND_FROM_EMAIL="findai <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3005" NEXT_PUBLIC_APP_URL="http://localhost:3005"
NEXT_PUBLIC_APP_NAME="findai" NEXT_PUBLIC_APP_NAME="findai"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="260 70% 52%"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="The minimal AI tool search engine."
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,8 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters"
}, },
"seo": { "seo": {
"home": { "home": {
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,8 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -0,0 +1,2 @@
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
@@ -0,0 +1,2 @@
// 自动生成:api/auth/mock-social/route.ts 的薄路由
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
+1
View File
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
// findai —— 独立站点的全部品牌/导航/页脚配置 // findai —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
"accent": "260 70% 52%",
"port": 3005, "port": 3005,
"brand": { "brand": {
"name": "findai", "name": "findai",
+6
View File
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>" RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3002" NEXT_PUBLIC_APP_URL="http://localhost:3002"
NEXT_PUBLIC_APP_NAME="LaunchAI" NEXT_PUBLIC_APP_NAME="LaunchAI"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="16 85% 55%"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="Discover and upvote today's AI products."
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,8 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters"
}, },
"seo": { "seo": {
"home": { "home": {
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,8 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -0,0 +1,2 @@
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
@@ -0,0 +1,2 @@
// 自动生成:api/auth/mock-social/route.ts 的薄路由
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
+1
View File
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
// LaunchAI —— 独立站点的全部品牌/导航/页脚配置 // LaunchAI —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
"accent": "16 85% 55%",
"port": 3002, "port": 3002,
"brand": { "brand": {
"name": "LaunchAI", "name": "LaunchAI",
+6
View File
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>" RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>"
NEXT_PUBLIC_APP_URL="http://localhost:3003" NEXT_PUBLIC_APP_URL="http://localhost:3003"
NEXT_PUBLIC_APP_NAME="The AI Ledger" NEXT_PUBLIC_APP_NAME="The AI Ledger"
# 登录页品牌模板(分栏侧栏颜色与标语)
NEXT_PUBLIC_AUTH_ACCENT="36 70% 40%"
NEXT_PUBLIC_AUTH_BRAND_TAGLINE="Independent journalism on AI tools, with a community-ranked directory."
# 开发阶段免邮箱验证(生产置 false 发送真实验证邮件)
DEV_SKIP_EMAIL_VERIFY="true"
NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY="true"
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "Continue with Google", "googleSignIn": "Continue with Google",
"or": "Or continue with", "or": "Or continue with",
"githubSignIn": "Continue with GitHub" "githubSignIn": "Continue with GitHub",
"orContinueWith": "or continue with",
"loading": "Signing in...",
"mockFailed": "Social sign-in is unavailable right now. Use email instead."
}, },
"checkEmail": { "checkEmail": {
"title": "Check Your Email", "title": "Check Your Email",
@@ -645,7 +648,8 @@
"sort": { "sort": {
"latest": "Latest", "latest": "Latest",
"trending": "Trending" "trending": "Trending"
} },
"clearFilters": "Clear search & filters"
}, },
"seo": { "seo": {
"home": { "home": {
+6 -2
View File
@@ -209,7 +209,10 @@
"social": { "social": {
"googleSignIn": "使用 Google 继续", "googleSignIn": "使用 Google 继续",
"or": "或者继续使用", "or": "或者继续使用",
"githubSignIn": "使用 GitHub 登录" "githubSignIn": "使用 GitHub 登录",
"orContinueWith": "或使用以下方式",
"loading": "登录中...",
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
}, },
"checkEmail": { "checkEmail": {
"title": "请查收您的邮件", "title": "请查收您的邮件",
@@ -653,7 +656,8 @@
"sort": { "sort": {
"latest": "最新", "latest": "最新",
"trending": "最热" "trending": "最热"
} },
"clearFilters": "清除搜索与筛选"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -0,0 +1,2 @@
// 自动生成:[locale]/(marketing)/loading.tsx 的薄路由
export { default } from "@aidir/core/pages/[locale]/(marketing)/loading";
@@ -0,0 +1,2 @@
// 自动生成:api/auth/mock-social/route.ts 的薄路由
export { POST } from "@aidir/core/pages/api/auth/mock-social/route";
+1
View File
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
// The AI Ledger —— 独立站点的全部品牌/导航/页脚配置 // The AI Ledger —— 独立站点的全部品牌/导航/页脚配置
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
"accent": "36 70% 40%",
"port": 3003, "port": 3003,
"brand": { "brand": {
"name": "The AI Ledger", "name": "The AI Ledger",