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:
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
|
||||
@@ -23,6 +23,7 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
|
||||
// ====================================================================
|
||||
const SITES = {
|
||||
"ai-stack": {
|
||||
accent: "222 47% 11%", // 近黑(专业)
|
||||
port: 3001,
|
||||
theme: "classic",
|
||||
brand: {
|
||||
@@ -81,6 +82,7 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
launchai: {
|
||||
accent: "16 85% 55%", // 橙红(产品社区)
|
||||
port: 3002,
|
||||
theme: "product",
|
||||
brand: {
|
||||
@@ -137,6 +139,7 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"the-ai-ledger": {
|
||||
accent: "36 70% 40%", // 纸墨棕(编辑部)
|
||||
port: 3003,
|
||||
theme: "editorial",
|
||||
brand: {
|
||||
@@ -194,6 +197,7 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"devai-tools": {
|
||||
accent: "150 60% 30%", // 终端绿(开发者)
|
||||
port: 3004,
|
||||
theme: "niche",
|
||||
brand: {
|
||||
@@ -249,6 +253,7 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
findai: {
|
||||
accent: "260 70% 52%", // 紫(极简搜索)
|
||||
port: 3005,
|
||||
theme: "minimal",
|
||||
brand: {
|
||||
@@ -684,6 +689,12 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="${site.brand.name} <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:${site.port}"
|
||||
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"
|
||||
`
|
||||
);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ToolCard({
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -85,7 +85,7 @@ export function ToolCard({
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<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}
|
||||
</h3>
|
||||
{pricing && (
|
||||
@@ -94,7 +94,7 @@ export function ToolCard({
|
||||
</span>
|
||||
)}
|
||||
{(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} />
|
||||
<span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span>
|
||||
</span>
|
||||
@@ -103,7 +103,7 @@ export function ToolCard({
|
||||
{tagline && (
|
||||
<p
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -109,6 +109,9 @@ export function LoginForm() {
|
||||
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
|
||||
control={form.control}
|
||||
|
||||
@@ -58,8 +58,16 @@ export function SignupForm() {
|
||||
console.error('发送验证邮件失败:', verificationError);
|
||||
}
|
||||
|
||||
// 跳转到邮箱验证提示页面,而不是直接登录
|
||||
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
|
||||
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
|
||||
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) {
|
||||
setError(t('errors.signupFailed'));
|
||||
} finally {
|
||||
@@ -67,12 +75,26 @@ export function SignupForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
async function handleGoogleSignIn(callbackURL?: string) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signIn.social({
|
||||
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) {
|
||||
setError(t('errors.googleSignupFailed'));
|
||||
@@ -98,7 +120,16 @@ export function SignupForm() {
|
||||
</Link>
|
||||
</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
|
||||
control={form.control}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"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 { useTranslations } from 'next-intl';
|
||||
|
||||
interface SocialAuthButtonsProps {
|
||||
onGoogleSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */
|
||||
function getCallbackURL(): string {
|
||||
@@ -10,15 +17,12 @@ function getCallbackURL(): string {
|
||||
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
|
||||
* 说明:第三方登录按钮组(Google / GitHub)。
|
||||
* - 配置了真实 OAuth 凭据(NEXT_PUBLIC_AUTH_GOOGLE_ID / NEXT_PUBLIC_AUTH_GITHUB_ID)→ 走 better-auth OAuth 跳转。
|
||||
* - 开发/演示环境未配置凭据 → 按钮依然显示,点击调用 /api/auth/mock-social 一键创建演示 session
|
||||
* (生产配置凭据后 mock 端点自动 403,按钮自动走真实流程)。
|
||||
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
|
||||
*/
|
||||
export function SocialAuthButtons({
|
||||
@@ -26,41 +30,68 @@ export function SocialAuthButtons({
|
||||
onGithubSignIn,
|
||||
isLoading,
|
||||
}: SocialAuthButtonsProps) {
|
||||
const t = useTranslations('auth.social');
|
||||
const googleEnabled = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ENABLED !== "false";
|
||||
const githubEnabled = process.env.NEXT_PUBLIC_AUTH_GITHUB_ENABLED === "true";
|
||||
const t = useTranslations("auth.social");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [mockLoading, setMockLoading] = useState<string | null>(null);
|
||||
const [mockError, setMockError] = useState(false);
|
||||
|
||||
const showGoogle = googleEnabled && !!onGoogleSignIn;
|
||||
const showGithub = githubEnabled && !!onGithubSignIn;
|
||||
const realGoogle = !!process.env.NEXT_PUBLIC_AUTH_GOOGLE_ID;
|
||||
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 (
|
||||
<div className={showGoogle && showGithub ? "flex flex-col gap-2" : undefined}>
|
||||
{showGoogle && (
|
||||
<Button
|
||||
onClick={() => onGoogleSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
realGoogle ? onGoogleSignIn?.(getCallbackURL()) : mockSignIn("google")
|
||||
}
|
||||
className={btnClass}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconBrandGoogle className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showGithub && (
|
||||
<Button
|
||||
onClick={() => onGithubSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
<span>{mockLoading === "google" ? t("loading") : t("googleSignIn")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
realGithub ? onGithubSignIn?.(getCallbackURL()) : mockSignIn("github")
|
||||
}
|
||||
className={btnClass}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('githubSignIn')}</span>
|
||||
</Button>
|
||||
<span>{mockLoading === "github" ? t("loading") : t("githubSignIn")}</span>
|
||||
</button>
|
||||
{mockError && (
|
||||
<p className="text-center text-xs text-red-500">{t("mockFailed")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getCallbackURL };
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ClaimToolButton({
|
||||
|
||||
if (claimed) {
|
||||
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" />
|
||||
{t("verifiedOwner")}
|
||||
</span>
|
||||
@@ -68,7 +68,7 @@ export function ClaimToolButton({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{t("claimThisTool")}
|
||||
@@ -77,11 +77,11 @@ export function ClaimToolButton({
|
||||
}
|
||||
|
||||
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 (
|
||||
<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
|
||||
className={inputClass}
|
||||
placeholder={t("rolePlaceholder")}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
|
||||
if (claims.length === 0) {
|
||||
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.
|
||||
</p>
|
||||
);
|
||||
@@ -54,24 +54,24 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{claimRow.status}
|
||||
</span>
|
||||
</div>
|
||||
{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 && (
|
||||
<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 && (
|
||||
<a
|
||||
href={claimRow.proofUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@@ -84,7 +84,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
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" />
|
||||
Approve
|
||||
@@ -93,7 +93,7 @@ export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
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" />
|
||||
Reject
|
||||
|
||||
@@ -42,7 +42,7 @@ interface DiscoveryManagerProps {
|
||||
type Tab = "useCases" | "audiences" | "collections";
|
||||
|
||||
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
|
||||
@@ -147,7 +147,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
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
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -177,7 +177,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitUseCase}
|
||||
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" />
|
||||
Create
|
||||
@@ -190,8 +190,8 @@ export function DiscoveryManager({
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{uc.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{uc.slug}</span>
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{uc.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">/{uc.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,7 +227,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitAudience}
|
||||
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" />
|
||||
Create
|
||||
@@ -240,8 +240,8 @@ export function DiscoveryManager({
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{aud.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{aud.slug}</span>
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{aud.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">/{aud.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -284,7 +284,7 @@ export function DiscoveryManager({
|
||||
type="button"
|
||||
onClick={submitCollection}
|
||||
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" />
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{col.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="text-[0.95rem] font-medium text-foreground">{col.name}</span>
|
||||
<span className="ml-2 text-[0.85rem] text-muted-foreground">
|
||||
/best/{col.slug} · {col.toolCount} tools
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,12 @@ interface FormShellProps<TFieldValues extends FieldValues> {
|
||||
className?: string;
|
||||
headerSlot?: React.ReactNode;
|
||||
socialSlot?: React.ReactNode;
|
||||
/** 站点品牌名(默认取环境变量,五站登录页各不相同) */
|
||||
brandName?: string;
|
||||
/** 品牌侧栏标语 */
|
||||
brandTagline?: string;
|
||||
/** 品牌强调色 HSL 三元组(如 "16 85% 55%"),传入即启用分栏品牌模板 */
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
export function FormShell<TFieldValues extends FieldValues>({
|
||||
@@ -39,9 +45,92 @@ export function FormShell<TFieldValues extends FieldValues>({
|
||||
className,
|
||||
headerSlot,
|
||||
socialSlot,
|
||||
brandName,
|
||||
brandTagline,
|
||||
accent,
|
||||
}: FormShellProps<TFieldValues>) {
|
||||
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 (
|
||||
<Form {...form}>
|
||||
@@ -51,53 +140,7 @@ export function FormShell<TFieldValues extends FieldValues>({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<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>
|
||||
{formColumn}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -144,7 +144,7 @@ export function ReviewsSection({
|
||||
)}
|
||||
|
||||
{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")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -156,11 +156,11 @@ export function ReviewsSection({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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()}
|
||||
</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.authorEmailVerified && (
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} />
|
||||
@@ -168,7 +168,7 @@ export function ReviewsSection({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[0.85rem] text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
)}
|
||||
@@ -181,8 +181,8 @@ export function ReviewsSection({
|
||||
onClick={() => onHelpful(review.id)}
|
||||
className={
|
||||
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 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 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-[0.85rem] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
@@ -191,24 +191,24 @@ export function ReviewsSection({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
{(review.pros || review.cons) && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{review.pros && (
|
||||
<div className="rounded-lg bg-emerald-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
<div className="rounded-lg bg-emerald-500/5 p-3 text-[0.95rem] text-foreground">
|
||||
<span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{t("pros")}
|
||||
</span>
|
||||
{review.pros}
|
||||
</div>
|
||||
)}
|
||||
{review.cons && (
|
||||
<div className="rounded-lg bg-red-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||
<div className="rounded-lg bg-red-500/5 p-3 text-[0.95rem] text-foreground">
|
||||
<span className="mb-1 block text-[0.85rem] font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||
{t("cons")}
|
||||
</span>
|
||||
{review.cons}
|
||||
@@ -286,7 +286,7 @@ function ReviewForm({
|
||||
};
|
||||
|
||||
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 (
|
||||
<form
|
||||
@@ -294,7 +294,7 @@ function ReviewForm({
|
||||
className="space-y-4 rounded-xl border border-border p-5"
|
||||
>
|
||||
<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")}
|
||||
</span>
|
||||
<StarRating value={rating} onChange={setRating} size="lg" />
|
||||
@@ -337,7 +337,7 @@ function ReviewForm({
|
||||
maxLength={200}
|
||||
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">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("saving") : existing ? t("update") : t("submit")}
|
||||
@@ -349,7 +349,7 @@ function ReviewForm({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{t("delete")}
|
||||
@@ -382,7 +382,7 @@ export function RatingSummary({
|
||||
</span>
|
||||
<div>
|
||||
<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 })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,7 +392,7 @@ export function RatingSummary({
|
||||
const count = distribution[star] ?? 0;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
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>
|
||||
<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">
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
|
||||
if (submissions.length === 0) {
|
||||
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.
|
||||
</p>
|
||||
);
|
||||
@@ -84,20 +84,20 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
href={sub.websiteUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{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 && (
|
||||
<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}
|
||||
</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.contactEmail ? ` · ${sub.contactEmail}` : ""}
|
||||
{sub.pricing ? ` · ${sub.pricing}` : ""}
|
||||
@@ -110,7 +110,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
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" />
|
||||
Approve
|
||||
@@ -119,7 +119,7 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
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" />
|
||||
Reject
|
||||
@@ -135,21 +135,21 @@ export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[]
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reject(sub.id)}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -92,12 +92,12 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
};
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t("duplicateFound")}
|
||||
@@ -107,7 +107,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
<button
|
||||
key={dup.slug}
|
||||
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}`)}
|
||||
>
|
||||
{dup.name}
|
||||
@@ -119,7 +119,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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")} *
|
||||
</label>
|
||||
<input
|
||||
@@ -134,7 +134,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</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")} *
|
||||
</label>
|
||||
<input
|
||||
@@ -150,7 +150,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</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")}
|
||||
</label>
|
||||
<input
|
||||
@@ -163,7 +163,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</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")} *
|
||||
</label>
|
||||
<textarea
|
||||
@@ -180,7 +180,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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")}
|
||||
</label>
|
||||
<select
|
||||
@@ -197,7 +197,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</select>
|
||||
</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")}
|
||||
</label>
|
||||
<select
|
||||
@@ -218,7 +218,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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")}
|
||||
</label>
|
||||
<input
|
||||
@@ -229,7 +229,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</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")}
|
||||
</label>
|
||||
<input
|
||||
@@ -242,7 +242,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</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")}
|
||||
</label>
|
||||
<input
|
||||
@@ -255,7 +255,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
</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")}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -268,14 +268,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
/>
|
||||
</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">
|
||||
<Button type="submit" disabled={submitting || checking}>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t("reviewNote")}</span>
|
||||
<span className="text-[0.85rem] text-muted-foreground">{t("reviewNote")}</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -141,7 +141,7 @@ export function FavoriteButton({
|
||||
type="button"
|
||||
onClick={() => ctx.toggle(toolId)}
|
||||
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
|
||||
? "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"
|
||||
|
||||
@@ -198,7 +198,7 @@ export function ToolsList({
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
<div className="text-[0.95rem] text-muted-foreground whitespace-nowrap">
|
||||
{translations.foundTools}
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@ export function ToolsList({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -219,7 +219,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={category.id}
|
||||
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
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -235,7 +235,7 @@ export function ToolsList({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -247,7 +247,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={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
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -258,7 +258,7 @@ export function ToolsList({
|
||||
))}
|
||||
</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>
|
||||
{(["newest", "trending"] as const).map((sort) => (
|
||||
<button
|
||||
@@ -282,7 +282,7 @@ export function ToolsList({
|
||||
<button
|
||||
key={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
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
@@ -310,7 +310,7 @@ export function ToolsList({
|
||||
{getName(tool)}
|
||||
</h3>
|
||||
{tool.domain && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
<p className="text-[0.95rem] text-muted-foreground truncate">
|
||||
{tool.domain}
|
||||
</p>
|
||||
)}
|
||||
@@ -322,14 +322,14 @@ export function ToolsList({
|
||||
</div>
|
||||
|
||||
{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)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
{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" />
|
||||
<span>{translations.visitWebsite}</span>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ export function ToolsList({
|
||||
<span />
|
||||
)}
|
||||
{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]}
|
||||
</span>
|
||||
)}
|
||||
@@ -354,7 +354,7 @@ export function ToolsList({
|
||||
</p>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
@@ -391,7 +391,7 @@ export function ToolsList({
|
||||
)}
|
||||
<button
|
||||
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
|
||||
? "bg-foreground text-background"
|
||||
: "hover:bg-hover"
|
||||
|
||||
@@ -163,7 +163,7 @@ export function VoteButton({
|
||||
if (!ctx) {
|
||||
// 未包裹 Provider 时渲染静态计数(服务端渲染回退)
|
||||
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" />
|
||||
{initialCount ?? 0}
|
||||
</span>
|
||||
@@ -193,7 +193,7 @@ export function VoteButton({
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export function VoteButton({
|
||||
onClick={() => ctx.toggle(toolId)}
|
||||
aria-pressed={voted}
|
||||
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
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-hover"
|
||||
@@ -227,7 +227,7 @@ export function VoteButton({
|
||||
ctx.toggle(toolId);
|
||||
}}
|
||||
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
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
|
||||
@@ -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">
|
||||
{brand.name.slice(0, 1)}
|
||||
</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>
|
||||
<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}
|
||||
</p>
|
||||
<div className="mt-4 max-w-xs">
|
||||
@@ -35,7 +35,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<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}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
@@ -43,7 +43,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
<li key={link.href + link.label}>
|
||||
<LocaleLink
|
||||
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}
|
||||
</LocaleLink>
|
||||
@@ -53,7 +53,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
</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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
{brand.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="text-sm font-bold tracking-tight text-foreground">
|
||||
<span className="text-[0.95rem] font-bold tracking-tight text-foreground">
|
||||
{brand.name}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
@@ -78,7 +78,7 @@ export function ThemeHeader({
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -99,7 +99,7 @@ export function ThemeHeader({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export function ThemeHeader({
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -151,7 +151,7 @@ export function ThemeHeader({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface SiteConfig {
|
||||
port: number;
|
||||
/** 站点 SEO 文案覆盖(en/zh) */
|
||||
seo?: Record<string, { title: string; description: string }>;
|
||||
/** 品牌强调色 HSL 三元组(登录页分栏模板) */
|
||||
accent?: string;
|
||||
/** 品牌侧栏标语(登录页) */
|
||||
brandTagline?: string;
|
||||
/** 主题级 CSS 变量覆盖(HSL 三元组,可选) */
|
||||
cssVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 }) {
|
||||
const t = useTranslations('contact.testimonial');
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-screen grid grid-cols-1 md:grid-cols-2">
|
||||
<div className={cn("w-full min-h-screen bg-background")}>
|
||||
{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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@ export const auth = betterAuth({
|
||||
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: {
|
||||
// Google(配置 env 后自动启用)
|
||||
...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||
@@ -44,6 +57,20 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
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";
|
||||
|
||||
@@ -36,7 +36,9 @@ export default async function LocaleLayout({
|
||||
<body
|
||||
className={cn(
|
||||
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
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="AI Stack <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3001"
|
||||
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"
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,8 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,8 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
},
|
||||
"seo": {
|
||||
"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";
|
||||
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// AI Stack —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"accent": "222 47% 11%",
|
||||
"port": 3001,
|
||||
"brand": {
|
||||
"name": "AI Stack",
|
||||
|
||||
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="DevAI.tools <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3004"
|
||||
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"
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,8 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,8 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
},
|
||||
"seo": {
|
||||
"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";
|
||||
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// DevAI.tools —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"accent": "150 60% 30%",
|
||||
"port": 3004,
|
||||
"brand": {
|
||||
"name": "DevAI.tools",
|
||||
|
||||
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="findai <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3005"
|
||||
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"
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,8 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,8 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
},
|
||||
"seo": {
|
||||
"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";
|
||||
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// findai —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"accent": "260 70% 52%",
|
||||
"port": 3005,
|
||||
"brand": {
|
||||
"name": "findai",
|
||||
|
||||
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="LaunchAI <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3002"
|
||||
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"
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,8 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,8 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
},
|
||||
"seo": {
|
||||
"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";
|
||||
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// LaunchAI —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"accent": "16 85% 55%",
|
||||
"port": 3002,
|
||||
"brand": {
|
||||
"name": "LaunchAI",
|
||||
|
||||
@@ -12,3 +12,9 @@ RESEND_API_KEY=""
|
||||
RESEND_FROM_EMAIL="The AI Ledger <noreply@localhost>"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3003"
|
||||
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"
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"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": {
|
||||
"title": "Check Your Email",
|
||||
@@ -645,7 +648,8 @@
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"clearFilters": "Clear search & filters"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
|
||||
@@ -209,7 +209,10 @@
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
"githubSignIn": "使用 GitHub 登录",
|
||||
"orContinueWith": "或使用以下方式",
|
||||
"loading": "登录中...",
|
||||
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -653,7 +656,8 @@
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"clearFilters": "清除搜索与筛选"
|
||||
},
|
||||
"seo": {
|
||||
"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";
|
||||
@@ -2,6 +2,7 @@ import type { SiteConfig } from "@/kit/types";
|
||||
|
||||
// The AI Ledger —— 独立站点的全部品牌/导航/页脚配置
|
||||
export const siteConfig: SiteConfig = {
|
||||
"accent": "36 70% 40%",
|
||||
"port": 3003,
|
||||
"brand": {
|
||||
"name": "The AI Ledger",
|
||||
|
||||
Reference in New Issue
Block a user