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": {
|
"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",
|
||||||
|
|||||||
@@ -209,7 +209,10 @@
|
|||||||
"social": {
|
"social": {
|
||||||
"googleSignIn": "使用 Google 继续",
|
"googleSignIn": "使用 Google 继续",
|
||||||
"or": "或者继续使用",
|
"or": "或者继续使用",
|
||||||
"githubSignIn": "使用 GitHub 登录"
|
"githubSignIn": "使用 GitHub 登录",
|
||||||
|
"orContinueWith": "或使用以下方式",
|
||||||
|
"loading": "登录中...",
|
||||||
|
"mockFailed": "第三方登录暂不可用,请使用邮箱登录。"
|
||||||
},
|
},
|
||||||
"checkEmail": {
|
"checkEmail": {
|
||||||
"title": "请查收您的邮件",
|
"title": "请查收您的邮件",
|
||||||
|
|||||||
@@ -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"
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到邮箱验证提示页面,而不是直接登录
|
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
|
||||||
router.push(`/${locale}/check-email`);
|
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) {
|
} 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,57 +17,81 @@ 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)。
|
||||||
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
|
* - 配置了真实 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({
|
export function SocialAuthButtons({
|
||||||
onGoogleSignIn,
|
onGoogleSignIn,
|
||||||
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
|
type="button"
|
||||||
onClick={() => onGoogleSignIn(getCallbackURL())}
|
onClick={() =>
|
||||||
className="w-full py-1.5"
|
realGoogle ? onGoogleSignIn?.(getCallbackURL()) : mockSignIn("google")
|
||||||
variant="outline"
|
}
|
||||||
disabled={isLoading}
|
className={btnClass}
|
||||||
type="button"
|
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 && (
|
type="button"
|
||||||
<Button
|
onClick={() =>
|
||||||
onClick={() => onGithubSignIn(getCallbackURL())}
|
realGithub ? onGithubSignIn?.(getCallbackURL()) : mockSignIn("github")
|
||||||
className="w-full py-1.5"
|
}
|
||||||
variant="outline"
|
className={btnClass}
|
||||||
disabled={isLoading}
|
disabled={loading}
|
||||||
type="button"
|
>
|
||||||
>
|
<IconBrandGithub className="h-5 w-5" />
|
||||||
<IconBrandGithub className="h-5 w-5" />
|
<span>{mockLoading === "github" ? t("loading") : t("githubSignIn")}</span>
|
||||||
<span className="text-sm font-semibold leading-6">{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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<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,
|
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";
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user