feat: 用户收藏、定价筛选、分类落地页、ISR 与 Newsletter/Docker 完善

- 收藏功能:favorite server actions + FavoritesProvider/FavoriteButton(乐观更新、未登录引导登录),列表卡片/详情页收藏按钮,仪表盘"我的收藏"
- 前台定价筛选(免费/免费增值/付费)与卡片定价徽标;首页"精选"改为查询 featured=true,不足时按最新补齐
- /category/[slug] 从 redirect 壳子改为真实落地页:分类双语描述 + 工具网格 + 完整 metadata + JSON-LD
- ISR:首页/工具详情/分类页 revalidate 3600 + generateStaticParams,[locale] layout 补 generateStaticParams;verify-email 与 reset-password 的 useSearchParams 包 Suspense
- 新增共享 ToolLogo 组件(next/image 优化 + data URL 降级 + 首字母占位),核心页面裸 img 全部替换;next.config 补 remotePatterns(R2 域名/Google favicon/https 通配)
- getCategoriesWithCount 只统计 published 工具;featured-tools 文案迁移至 next-intl
- Newsletter:footer 订阅表单 + 落库 + 可选同步 Resend Audience + 退订路由
- Docker:多阶段 Dockerfile(standalone)+ docker-compose(含 Postgres)+ 构建期数据库不可用的容错回退
- i18n:新增 pnpm i18n:check 一致性检查并补齐双语缺失键

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CoolChange.AI
2026-07-31 20:35:17 -07:00
co-authored by Claude Fable 5
parent 89a9e318c2
commit 845503ad2f
31 changed files with 1228 additions and 201 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.*
!.env.example
Dockerfile
docker-compose.yml
*.md
.claude
.vscode
.idea
+38
View File
@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
# ==================== 依赖安装 ====================
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ==================== 构建 ====================
FROM node:22-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# 构建期占位变量:镜像构建阶段连不上数据库时,
# 动态页面会自动回退为运行时按需生成(ISR),真实值在运行时注入
ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build"
ENV BETTER_AUTH_SECRET="build-time-placeholder-secret-32-chars!!"
RUN pnpm build
# ==================== 运行 ====================
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+39
View File
@@ -0,0 +1,39 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: toolso
POSTGRES_PASSWORD: toolso
POSTGRES_DB: toolso
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U toolso"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://toolso:toolso@db:5432/toolso
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-please-change-me-to-a-random-32-char-secret}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
depends_on:
db:
condition: service_healthy
volumes:
db-data:
# 首次启动后初始化数据库(在宿主机执行):
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm db:push
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm seed:tools
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm admin:setup
+49 -5
View File
@@ -22,7 +22,9 @@
"update": "Update", "update": "Update",
"continue": "Continue", "continue": "Continue",
"back": "Back", "back": "Back",
"next": "Next" "next": "Next",
"contactSales": "Contact Sales",
"startTrial": "Start Free Trial"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -442,6 +444,11 @@
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"securitySettings": "Security Settings" "securitySettings": "Security Settings"
} }
},
"favorites": {
"title": "My Favorites",
"empty": "No favorite tools yet.",
"browseTools": "Browse tools and save your favorites"
} }
}, },
"profile": { "profile": {
@@ -459,8 +466,20 @@
}, },
"accountSettings": { "accountSettings": {
"title": "Account Settings", "title": "Account Settings",
"memberSince": "Member Since" "memberSince": "Member Since",
"lastLogin": "Last Login",
"accountType": "Account Type"
} }
},
"form": {
"nameLabel": "Name",
"namePlaceholder": "Enter your name",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"updateButton": "Update Profile",
"updating": "Updating...",
"updateSuccess": "Profile updated successfully!",
"updateError": "Failed to update profile. Please try again."
} }
}, },
"contact": { "contact": {
@@ -539,7 +558,8 @@
"company": { "company": {
"title": "Company", "title": "Company",
"about": "About", "about": "About",
"blog": "Blog" "blog": "Blog",
"careers": "Careers"
}, },
"legal": { "legal": {
"title": "Legal", "title": "Legal",
@@ -557,7 +577,10 @@
"title": "Subscribe to Newsletter", "title": "Subscribe to Newsletter",
"description": "Get the latest updates and news directly in your inbox.", "description": "Get the latest updates and news directly in your inbox.",
"placeholder": "Enter your email", "placeholder": "Enter your email",
"subscribe": "Subscribe" "subscribe": "Subscribe",
"success": "Subscribed! Thanks for joining.",
"invalidEmail": "Please enter a valid email address.",
"error": "Subscription failed. Please try again."
} }
}, },
"tools": { "tools": {
@@ -576,7 +599,21 @@
"toolInfo": "Tool Info", "toolInfo": "Tool Info",
"domain": "Domain", "domain": "Domain",
"added": "Added", "added": "Added",
"notFound": "Tool Not Found" "notFound": "Tool Not Found",
"favorites": {
"add": "Save to Favorites",
"remove": "Remove from Favorites",
"loginRequired": "Please sign in to save favorites",
"error": "Action failed. Please try again."
},
"pricing": {
"free": "Free",
"freemium": "Freemium",
"paid": "Paid"
},
"categoryPage": {
"viewAll": "View all {count} tools"
}
}, },
"seo": { "seo": {
"home": { "home": {
@@ -623,5 +660,12 @@
"title": "AI Tools Directory - Discover the Best AI Tools", "title": "AI Tools Directory - Discover the Best AI Tools",
"description": "Explore our curated collection of AI tools. Find the best AI tools for productivity, creativity, and more." "description": "Explore our curated collection of AI tools. Find the best AI tools for productivity, creativity, and more."
} }
},
"featuredTools": {
"title": "Featured Tools",
"subtitle": "Curated collection of AI tools to boost your productivity",
"visitWebsite": "Visit website",
"empty": "No tools yet. Add some in the admin panel.",
"viewAll": "View All Tools"
} }
} }
+33 -3
View File
@@ -421,7 +421,8 @@
"previous": "上一页", "previous": "上一页",
"next": "下一页", "next": "下一页",
"page": "第 {current} / {total} 页" "page": "第 {current} / {total} 页"
} },
"cancel": "取消"
} }
}, },
"dashboard": { "dashboard": {
@@ -443,6 +444,11 @@
"accountSettings": "账户设置", "accountSettings": "账户设置",
"securitySettings": "安全设置" "securitySettings": "安全设置"
} }
},
"favorites": {
"title": "我的收藏",
"empty": "还没有收藏的工具。",
"browseTools": "去浏览工具并收藏"
} }
}, },
"profile": { "profile": {
@@ -579,7 +585,10 @@
"title": "订阅邮件", "title": "订阅邮件",
"description": "获取最新动态和资讯,直接发送到您的邮箱。", "description": "获取最新动态和资讯,直接发送到您的邮箱。",
"placeholder": "输入您的邮箱", "placeholder": "输入您的邮箱",
"subscribe": "订阅" "subscribe": "订阅",
"success": "订阅成功!感谢加入。",
"invalidEmail": "请输入有效的邮箱地址。",
"error": "订阅失败,请重试。"
} }
}, },
"tools": { "tools": {
@@ -598,7 +607,21 @@
"toolInfo": "工具信息", "toolInfo": "工具信息",
"domain": "域名", "domain": "域名",
"added": "添加时间", "added": "添加时间",
"notFound": "工具未找到" "notFound": "工具未找到",
"favorites": {
"add": "收藏",
"remove": "取消收藏",
"loginRequired": "请先登录后再收藏",
"error": "操作失败,请重试。"
},
"pricing": {
"free": "免费",
"freemium": "免费增值",
"paid": "付费"
},
"categoryPage": {
"viewAll": "查看该分类全部 {count} 个工具"
}
}, },
"seo": { "seo": {
"home": { "home": {
@@ -645,5 +668,12 @@
"title": "AI 工具库 - 发现最好的 AI 工具", "title": "AI 工具库 - 发现最好的 AI 工具",
"description": "浏览精选的 AI 工具集合。找到提升效率、激发创意的最佳 AI 工具。" "description": "浏览精选的 AI 工具集合。找到提升效率、激发创意的最佳 AI 工具。"
} }
},
"featuredTools": {
"title": "精选工具",
"subtitle": "精选优质 AI 工具,助力提升工作效率",
"visitWebsite": "访问网站",
"empty": "暂无工具,请先在后台添加",
"viewAll": "查看全部工具"
} }
} }
+21
View File
@@ -13,6 +13,8 @@ const snapshotManagedPaths = fs.existsSync(pnpmManagedPath) ? [pnpmManagedPath]
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Docker 部署使用 standalone 输出
output: "standalone",
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -31,6 +33,25 @@ const nextConfig = {
protocol: "https", protocol: "https",
hostname: "lh3.googleusercontent.com", hostname: "lh3.googleusercontent.com",
}, },
// Google favicon 服务(种子数据 logo 来源之一)
{
protocol: "https",
hostname: "www.google.com",
},
// R2/S3 公共访问域名(上传的 logo/封面)
...(process.env.STORAGE_PUBLIC_URL
? [
{
protocol: "https",
hostname: new URL(process.env.STORAGE_PUBLIC_URL).hostname,
},
]
: []),
// 工具 logo 指向各工具官网的 favicon,域名不可枚举,放开 https 通配
{
protocol: "https",
hostname: "**",
},
], ],
}, },
pageExtensions: ["ts", "tsx", "mdx"], pageExtensions: ["ts", "tsx", "mdx"],
+2 -1
View File
@@ -39,7 +39,8 @@
"admin:setup": "tsx scripts/setup-admin.ts", "admin:setup": "tsx scripts/setup-admin.ts",
"seed:tools": "tsx scripts/seed-tools.ts", "seed:tools": "tsx scripts/seed-tools.ts",
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs", "generate:blog-manifest": "node scripts/generate-blog-manifest.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"i18n:check": "node scripts/check-i18n.mjs"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/client-s3": "^3.958.0",
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* i18n 键一致性检查:对比 messages/ 下所有语言文件的键集合,
* 存在差异时打印缺失键并以非零码退出(可挂进 CI)。
*
* 用法: pnpm i18n:check
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const messagesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "messages");
function flattenKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return value && typeof value === "object" && !Array.isArray(value)
? flattenKeys(value, path)
: [path];
});
}
const files = readdirSync(messagesDir).filter((f) => f.endsWith(".json"));
const keySets = new Map(
files.map((f) => [
f,
new Set(flattenKeys(JSON.parse(readFileSync(join(messagesDir, f), "utf8")))),
])
);
const allKeys = new Set([...keySets.values()].flatMap((s) => [...s]));
let hasError = false;
for (const [file, keys] of keySets) {
const missing = [...allKeys].filter((k) => !keys.has(k));
if (missing.length > 0) {
hasError = true;
console.error(`\n❌ ${file} 缺失 ${missing.length} 个键:`);
for (const key of missing.sort()) console.error(` - ${key}`);
}
}
if (hasError) {
process.exit(1);
}
console.log(`✅ i18n 键一致 (${files.join(", ")},共 ${allKeys.size} 个键)`);
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { Suspense, useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { z } from "zod"; import { z } from "zod";
@@ -24,7 +24,7 @@ const resetPasswordSchema = z.object({
type ResetPasswordInput = z.infer<typeof resetPasswordSchema>; type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export default function ResetPasswordPage() { function ResetPasswordPageInner() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const t = useTranslations(); const t = useTranslations();
@@ -186,3 +186,11 @@ export default function ResetPasswordPage() {
</FormShell> </FormShell>
); );
} }
export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordPageInner />
</Suspense>
);
}
@@ -1,38 +1,187 @@
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { getCategoryBySlug } from "@/features/tools/actions"; import Link from "next/link";
import { getLocale } from "next-intl/server"; import { db } from "@/lib/db";
import { category as categoryTable } from "@/lib/db/schema";
import {
getCategoryBySlug,
getPublishedTools,
} from "@/features/tools/actions";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n.config";
import { ArrowRight, ExternalLink } from "lucide-react";
import { CollectionPageJsonLd } from "@/components/json-ld";
import { ToolLogo } from "@/components/tool-logo";
import {
FavoritesProvider,
FavoriteButton,
} from "@/features/tools/components/favorite-button";
import { absoluteUrl, generatePageMetadata } from "@/lib/metadata";
interface CategoryPageProps { interface CategoryPageProps {
params: Promise<{ params: Promise<{
locale: string;
slug: string; slug: string;
}>; }>;
} }
export async function generateMetadata({ params }: CategoryPageProps) { // 分类落地页走 ISR,工具增删时由 revalidatePath 触发更新
const { slug } = await params; export const revalidate = 3600;
const category = await getCategoryBySlug(slug);
if (!category) { const PAGE_SIZE = 24;
return {
title: "Category Not Found", export async function generateStaticParams() {
}; try {
const categories = await db
.select({ slug: categoryTable.slug })
.from(categoryTable);
return categories.flatMap((c) =>
locales.map((locale) => ({ locale, slug: c.slug }))
);
} catch {
// 构建时数据库不可用(如 Docker 构建),改为运行时按需生成
return [];
}
} }
return { export async function generateMetadata({ params }: CategoryPageProps) {
title: `${category.nameEn} - AI Tools`, const { slug, locale } = await params;
description: category.descriptionEn || `Explore ${category.nameEn} AI tools`, const category = await getCategoryBySlug(slug);
}; const t = await getTranslations({ locale, namespace: "tools" });
if (!category) {
return { title: t("notFound") };
}
const name =
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
const description =
locale === "zh" && category.descriptionZh
? category.descriptionZh
: category.descriptionEn;
return generatePageMetadata({
locale,
path: `/category/${slug}`,
title: `${name} - ${t("title")}`,
description: description || `${t("subtitle")} - ${name}`,
});
} }
export default async function CategoryPage({ params }: CategoryPageProps) { export default async function CategoryPage({ params }: CategoryPageProps) {
const { slug } = await params; const { slug, locale } = await params;
const locale = await getLocale(); setRequestLocale(locale);
const category = await getCategoryBySlug(slug); const category = await getCategoryBySlug(slug);
if (!category) { if (!category) {
notFound(); notFound();
} }
// 重定向到工具列表页并带上分类参数 const t = await getTranslations({ locale, namespace: "tools" });
redirect(`/${locale}/tools?category=${slug}`); const { tools, total } = await getPublishedTools({
page: 1,
pageSize: PAGE_SIZE,
categoryId: category.id,
});
const name =
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
const description =
locale === "zh" && category.descriptionZh
? category.descriptionZh
: category.descriptionEn;
const getName = (tool: { nameEn: string; nameZh: string | null }) =>
locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
const getDescription = (tool: {
descriptionEn: string | null;
descriptionZh: string | null;
}) =>
locale === "zh" && tool.descriptionZh
? tool.descriptionZh
: tool.descriptionEn;
return (
<div className="container mx-auto px-4 pt-24 pb-16">
<CollectionPageJsonLd
name={name}
description={description || name}
url={absoluteUrl(locale, `/category/${slug}`)}
itemCount={total}
/>
{/* 分类头部 */}
<div className="mx-auto mb-12 max-w-2xl text-center">
{category.icon && <div className="text-5xl">{category.icon}</div>}
<h1 className="mt-4 text-4xl font-bold text-foreground">{name}</h1>
{description && (
<p className="mt-4 text-lg text-muted-foreground">{description}</p>
)}
<p className="mt-2 text-sm text-muted-foreground">
{t("foundTools", { count: total })}
</p>
</div>
{/* 工具网格 */}
<FavoritesProvider>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => (
<Link
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
className="group rounded-xl border border-border bg-background p-5 transition-all hover:border-foreground/20 hover:shadow-lg"
>
<div className="flex items-start gap-4">
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-foreground group-hover:text-primary">
{getName(tool)}
</h3>
{tool.domain && (
<p className="truncate text-sm text-muted-foreground">
{tool.domain}
</p>
)}
</div>
<FavoriteButton toolId={tool.id} />
</div>
{getDescription(tool) && (
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{getDescription(tool)}
</p>
)}
{tool.websiteUrl && (
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" />
<span>{t("visitWebsite")}</span>
</div>
)}
</Link>
))}
</div>
</FavoritesProvider>
{/* 空状态 */}
{tools.length === 0 && (
<div className="py-16 text-center text-muted-foreground">
{t("noTools")}
</div>
)}
{/* 查看全部 */}
{total > PAGE_SIZE && (
<div className="mt-12 flex justify-center">
<Link
href={`/${locale}/tools?category=${slug}`}
className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 font-medium text-background transition-opacity hover:opacity-90"
>
{t("categoryPage.viewAll", { count: total })}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
)}
</div>
);
} }
+21 -7
View File
@@ -5,10 +5,10 @@ import { CTA } from "@/components/cta";
import { FeaturedTools } from "@/components/featured-tools"; import { FeaturedTools } from "@/components/featured-tools";
import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld"; import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld";
import { Metadata } from "next"; import { Metadata } from "next";
import { getTranslations, getLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from "@/i18n.config"; import type { Locale } from "@/i18n.config";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions"; import { getFeaturedTools, getCategoriesWithCount } from "@/features/tools/actions";
export async function generateMetadata({ export async function generateMetadata({
params params
@@ -26,15 +26,29 @@ export async function generateMetadata({
}); });
} }
export default async function Home() { // 首页内容变化不频繁,ISR 每小时再生
const locale = await getLocale(); export const revalidate = 3600;
export default async function Home({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai'; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
// 获取精选工具和分类数据 // 获取精选工具和分类数据(构建时数据库不可用则先渲染空态,运行时 ISR 补全)
const [{ tools }, categories] = await Promise.all([ let tools: Awaited<ReturnType<typeof getFeaturedTools>> = [];
getPublishedTools({ pageSize: 8 }), let categories: Awaited<ReturnType<typeof getCategoriesWithCount>> = [];
try {
[tools, categories] = await Promise.all([
getFeaturedTools(8),
getCategoriesWithCount(), getCategoriesWithCount(),
]); ]);
} catch {
console.warn("home: database unavailable at build time");
}
return ( return (
<div className="relative"> <div className="relative">
@@ -1,22 +1,51 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { tool as toolTable } from "@/lib/db/schema";
import { getToolBySlug } from "@/features/tools/actions"; import { getToolBySlug } from "@/features/tools/actions";
import { getLocale, getTranslations } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n.config";
import { ExternalLink, ArrowLeft, Tag } from "lucide-react"; import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { SoftwareApplicationJsonLd } from "@/components/json-ld"; import { SoftwareApplicationJsonLd } from "@/components/json-ld";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
import {
FavoritesProvider,
FavoriteButton,
} from "@/features/tools/components/favorite-button";
import { ToolLogo } from "@/components/tool-logo";
interface ToolDetailPageProps { interface ToolDetailPageProps {
params: Promise<{ params: Promise<{
locale: string;
slug: string; slug: string;
}>; }>;
} }
// 工具详情内容基本不变,ISR 每小时再生;后台变更会触发 revalidatePath
export const revalidate = 3600;
export async function generateStaticParams() {
try {
const tools = await db
.select({ slug: toolTable.slug })
.from(toolTable)
.where(eq(toolTable.status, "published"));
return tools.flatMap((t) =>
locales.map((locale) => ({ locale, slug: t.slug }))
);
} catch {
// 构建时数据库不可用(如 Docker 构建),改为运行时按需生成
return [];
}
}
export async function generateMetadata({ params }: ToolDetailPageProps) { export async function generateMetadata({ params }: ToolDetailPageProps) {
const { slug } = await params; const { slug, locale } = await params;
const locale = await getLocale(); const t = await getTranslations({ locale, namespace: "tools" });
const t = await getTranslations("tools");
const tool = await getToolBySlug(slug); const tool = await getToolBySlug(slug);
if (!tool) { if (!tool) {
@@ -41,9 +70,9 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
} }
export default async function ToolDetailPage({ params }: ToolDetailPageProps) { export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
const { slug } = await params; const { slug, locale } = await params;
const locale = await getLocale(); setRequestLocale(locale);
const t = await getTranslations("tools"); const t = await getTranslations({ locale, namespace: "tools" });
const tool = await getToolBySlug(slug); const tool = await getToolBySlug(slug);
if (!tool) { if (!tool) {
@@ -91,19 +120,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<div className="lg:col-span-2 space-y-8"> <div className="lg:col-span-2 space-y-8">
{/* 工具头部 */} {/* 工具头部 */}
<div className="flex items-start gap-6"> <div className="flex items-start gap-6">
{tool.logoUrl ? ( <ToolLogo src={tool.logoUrl} alt={name} size={80} className="rounded-2xl" />
<img
src={tool.logoUrl}
alt={name}
className="h-20 w-20 rounded-2xl object-cover"
/>
) : (
<div className="h-20 w-20 rounded-2xl bg-secondary flex items-center justify-center">
<span className="text-3xl font-bold text-muted-foreground">
{name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1"> <div className="flex-1">
<h1 className="text-3xl font-bold text-foreground mb-2"> <h1 className="text-3xl font-bold text-foreground mb-2">
{name} {name}
@@ -117,10 +134,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* 封面图 */} {/* 封面图 */}
{tool.coverImageUrl && ( {tool.coverImageUrl && (
<div className="rounded-xl overflow-hidden border border-border"> <div className="rounded-xl overflow-hidden border border-border">
<img <Image
src={tool.coverImageUrl} src={tool.coverImageUrl}
alt={name} alt={name}
width={1200}
height={630}
className="w-full h-auto" className="w-full h-auto"
sizes="(max-width: 1024px) 100vw, 66vw"
/> />
</div> </div>
)} )}
@@ -199,6 +219,11 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</a> </a>
)} )}
{/* 收藏按钮 */}
<FavoritesProvider>
<FavoriteButton toolId={tool.id} appearance="button" />
</FavoritesProvider>
{/* 工具信息卡片 */} {/* 工具信息卡片 */}
<div className="bg-secondary rounded-xl p-6 space-y-4"> <div className="bg-secondary rounded-xl p-6 space-y-4">
<h3 className="font-semibold text-foreground"> <h3 className="font-semibold text-foreground">
@@ -1,5 +1,6 @@
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions"; import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
import { ToolsList } from "@/features/tools/components/tools-list"; import { ToolsList } from "@/features/tools/components/tools-list";
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
import { CollectionPageJsonLd } from "@/components/json-ld"; import { CollectionPageJsonLd } from "@/components/json-ld";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { generatePageMetadata } from "@/lib/metadata"; import { generatePageMetadata } from "@/lib/metadata";
@@ -24,6 +25,7 @@ interface ToolsPageProps {
page?: string; page?: string;
category?: string; category?: string;
search?: string; search?: string;
pricing?: string;
}>; }>;
} }
@@ -34,6 +36,11 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
const page = parseInt(params.page || "1", 10); const page = parseInt(params.page || "1", 10);
const categorySlug = params.category; const categorySlug = params.category;
const search = params.search; const search = params.search;
const pricingParam = params.pricing;
const pricing =
pricingParam === "free" || pricingParam === "freemium" || pricingParam === "paid"
? pricingParam
: undefined;
// 获取分类列表用于筛选 // 获取分类列表用于筛选
const categories = await getCategoriesWithCount(); const categories = await getCategoriesWithCount();
@@ -51,6 +58,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
pageSize: 12, pageSize: 12,
categoryId, categoryId,
search, search,
pricing,
}); });
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
@@ -64,6 +72,9 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
foundTools: t("foundTools", { count: total }), foundTools: t("foundTools", { count: total }),
visitWebsite: t("visitWebsite"), visitWebsite: t("visitWebsite"),
noTools: t("noTools"), noTools: t("noTools"),
pricingFree: t("pricing.free"),
pricingFreemium: t("pricing.freemium"),
pricingPaid: t("pricing.paid"),
}; };
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai'; const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
@@ -87,10 +98,12 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
</p> </p>
</div> </div>
<FavoritesProvider>
<ToolsList <ToolsList
tools={tools} tools={tools}
categories={categories} categories={categories}
currentCategory={categorySlug} currentCategory={categorySlug}
currentPricing={pricing}
currentSearch={search} currentSearch={search}
currentPage={page} currentPage={page}
totalPages={totalPages} totalPages={totalPages}
@@ -98,6 +111,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
locale={locale} locale={locale}
translations={translations} translations={translations}
/> />
</FavoritesProvider>
</div> </div>
); );
} }
@@ -7,6 +7,7 @@ import { Button } from "@/components/button";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import { Background } from "@/components/background"; import { Background } from "@/components/background";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -134,6 +135,15 @@ export default function DashboardPage() {
Sign Out Sign Out
</Button> </Button>
</motion.div> </motion.div>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ease: "easeOut", duration: 0.5, delay: 0.2 }}
className="mt-6"
>
<FavoriteToolsSection />
</motion.div>
</Container> </Container>
</div> </div>
); );
+4
View File
@@ -10,6 +10,10 @@ import { Toaster } from "sonner";
import Analytics from "@/analytics/analytics"; import Analytics from "@/analytics/analytics";
import "../globals.css"; import "../globals.css";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ export default async function LocaleLayout({
children, children,
params params
+9 -2
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
export default function VerifyEmailPage() { function VerifyEmailPageInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const locale = useLocale(); const locale = useLocale();
@@ -141,3 +141,10 @@ export default function VerifyEmailPage() {
</div> </div>
); );
} }
export default function VerifyEmailPage() {
return (
<Suspense fallback={null}>
<VerifyEmailPageInner />
</Suspense>
);
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema";
/**
* 退订链接处理:GET /api/newsletter/unsubscribe?token=xxx
*/
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const [subscription] = await db
.select()
.from(newsletterSubscription)
.where(eq(newsletterSubscription.unsubscribeToken, token))
.limit(1);
if (!subscription) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
if (subscription.status !== "unsubscribed") {
await db
.update(newsletterSubscription)
.set({ status: "unsubscribed", unsubscribedAt: new Date() })
.where(eq(newsletterSubscription.id, subscription.id));
}
return new NextResponse(
"<html><body style=\"font-family:sans-serif;text-align:center;padding-top:80px\"><h1>Unsubscribed</h1><p>You have been unsubscribed from the Toolso.AI newsletter.</p></body></html>",
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
);
}
-65
View File
@@ -2,56 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { uploadImageFromUrl } from "@/lib/r2-storage"; import { uploadImageFromUrl } from "@/lib/r2-storage";
// Simple image dimension detection for common formats
async function getImageDimensions(buffer: Buffer, mimeType: string): Promise<{ width: number; height: number } | null> {
try {
// For JPEG
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
// JPEG starts with FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8) {
let offset = 2;
while (offset < buffer.length) {
if (buffer[offset] !== 0xFF) break;
const marker = buffer[offset + 1];
if (marker === 0xC0 || marker === 0xC2) { // SOF0 or SOF2
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { width, height };
}
offset += 2 + buffer.readUInt16BE(offset + 2);
}
}
}
// For PNG
if (mimeType === 'image/png') {
// PNG starts with 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { width, height };
}
}
// For WebP
if (mimeType === 'image/webp') {
// WebP starts with RIFF
if (buffer.toString('utf8', 0, 4) === 'RIFF') {
const width = buffer.readUInt16LE(26) + 1;
const height = buffer.readUInt16LE(28) + 1;
return { width, height };
}
}
return null;
} catch (error) {
console.error("Error detecting image dimensions:", error);
return null;
}
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
// Authenticate user // Authenticate user
@@ -87,21 +37,6 @@ export async function POST(req: NextRequest) {
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// For video generation, check minimum image dimensions
// Volcano Engine requires minimum 300px height
try {
// Simple dimension check for common formats
const dimensions = await getImageDimensions(buffer, file.type);
if (dimensions && (dimensions.width < 300 || dimensions.height < 300)) {
return NextResponse.json({
error: "Image must be at least 300x300 pixels for video generation"
}, { status: 400 });
}
} catch (err) {
console.warn("Could not validate image dimensions:", err);
// Continue anyway if we can't determine dimensions
}
// Generate unique filename // Generate unique filename
const timestamp = Date.now(); const timestamp = Date.now();
const random = Math.random().toString(36).substring(7); const random = Math.random().toString(36).substring(7);
+10 -1
View File
@@ -21,7 +21,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 }, { path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
] ]
const [tools, categories, blogs] = await Promise.all([ // 构建环境可能连不上数据库(如 Docker 镜像构建),退化为仅静态路由,
// 运行时依靠 revalidate 补全动态条目
let tools: { slug: string; updatedAt: Date }[] = []
let categories: { slug: string; updatedAt: Date }[] = []
let blogs: Awaited<ReturnType<typeof getAllBlogs>> = []
try {
;[tools, categories, blogs] = await Promise.all([
db db
.select({ slug: tool.slug, updatedAt: tool.updatedAt }) .select({ slug: tool.slug, updatedAt: tool.updatedAt })
.from(tool) .from(tool)
@@ -29,6 +35,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category), db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category),
getAllBlogs(), getAllBlogs(),
]) ])
} catch (error) {
console.warn('sitemap: database unavailable, falling back to static routes only')
}
const dynamicRoutes = [ const dynamicRoutes = [
...tools.map((t) => ({ ...tools.map((t) => ({
+9 -25
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react"; import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading"; import { Heading } from "./heading";
import { Subheading } from "./subheading"; import { Subheading } from "./subheading";
import { ToolLogo } from "./tool-logo";
interface ToolData { interface ToolData {
id: string; id: string;
@@ -34,6 +35,7 @@ interface FeaturedToolsProps {
export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("featuredTools");
const getName = (item: { nameEn: string; nameZh: string | null }) => const getName = (item: { nameEn: string; nameZh: string | null }) =>
locale === "zh" && item.nameZh ? item.nameZh : item.nameEn; locale === "zh" && item.nameZh ? item.nameZh : item.nameEn;
@@ -45,14 +47,8 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
return ( return (
<div className="relative z-20"> <div className="relative z-20">
<Heading as="h2"> <Heading as="h2">{t("title")}</Heading>
{locale === "zh" ? "精选工具" : "Featured Tools"} <Subheading className="text-center">{t("subtitle")}</Subheading>
</Heading>
<Subheading className="text-center">
{locale === "zh"
? "精选优质 AI 工具,助力提升工作效率"
: "Curated collection of AI tools to boost your productivity"}
</Subheading>
{/* 分类导航 */} {/* 分类导航 */}
{categories.length > 0 && ( {categories.length > 0 && (
@@ -82,19 +78,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20" className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{tool.logoUrl ? ( <ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<img
src={tool.logoUrl}
alt={getName(tool)}
className="h-12 w-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-secondary flex items-center justify-center flex-shrink-0">
<span className="text-xl font-bold text-muted-foreground">
{getName(tool).charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate"> <h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)} {getName(tool)}
@@ -116,7 +100,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{tool.websiteUrl && ( {tool.websiteUrl && (
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground"> <div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
<span>{locale === "zh" ? "访问网站" : "Visit website"}</span> <span>{t("visitWebsite")}</span>
</div> </div>
)} )}
</Link> </Link>
@@ -126,7 +110,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 空状态 */} {/* 空状态 */}
{tools.length === 0 && ( {tools.length === 0 && (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
{locale === "zh" ? "暂无工具,请先在后台添加" : "No tools yet. Add some in the admin panel."} {t("empty")}
</div> </div>
)} )}
@@ -137,7 +121,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
href={`/${locale}/tools`} href={`/${locale}/tools`}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium" className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
> >
{locale === "zh" ? "查看全部工具" : "View All Tools"} {t("viewAll")}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </Link>
</div> </div>
+2
View File
@@ -3,6 +3,7 @@ import React from "react";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { LocaleLink } from './locale-link'; import { LocaleLink } from './locale-link';
import { NewsletterForm } from '@/features/marketing/components/newsletter-form';
export const Footer = () => { export const Footer = () => {
const t = useTranslations(); const t = useTranslations();
@@ -55,6 +56,7 @@ export const Footer = () => {
</div> </div>
<div>{t('common.brand.copyright')}</div> <div>{t('common.brand.copyright')}</div>
<div className="mt-2">{t('common.brand.allRightsReserved')}</div> <div className="mt-2">{t('common.brand.allRightsReserved')}</div>
<NewsletterForm />
</div> </div>
<div className="grid grid-cols-3 gap-10 items-start mt-10 md:mt-0"> <div className="grid grid-cols-3 gap-10 items-start mt-10 md:mt-0">
<div className="flex justify-center space-y-4 flex-col mt-4"> <div className="flex justify-center space-y-4 flex-col mt-4">
+60
View File
@@ -0,0 +1,60 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
interface ToolLogoProps {
src: string | null;
alt: string;
size?: number;
className?: string;
}
/**
* 工具 Logo:远程图走 next/image 优化;
* data: URL(未配置 R2 时的上传回退)next/image 不支持,降级为原生 img;
* 无图时显示首字母占位。
*/
export function ToolLogo({ src, alt, size = 48, className }: ToolLogoProps) {
const style = { width: size, height: size };
if (!src) {
return (
<div
style={style}
className={cn(
"flex flex-shrink-0 items-center justify-center rounded-lg bg-secondary",
className
)}
>
<span
className="font-bold text-muted-foreground"
style={{ fontSize: size * 0.4 }}
>
{alt.charAt(0).toUpperCase()}
</span>
</div>
);
}
if (src.startsWith("data:")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
style={style}
className={cn("flex-shrink-0 rounded-lg object-cover", className)}
/>
);
}
return (
<Image
src={src}
alt={alt}
width={size}
height={size}
style={style}
className={cn("flex-shrink-0 rounded-lg object-cover", className)}
/>
);
}
@@ -0,0 +1,73 @@
"use server";
import { z } from "zod";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
const emailSchema = z.string().email().max(255);
export async function subscribeNewsletter(email: string) {
const parsed = emailSchema.safeParse(email?.trim().toLowerCase());
if (!parsed.success) {
return { success: false as const, error: "INVALID_EMAIL" };
}
const normalizedEmail = parsed.data;
const session = await auth.api.getSession({ headers: await headers() });
const userId = session?.user?.id ?? null;
try {
const [existing] = await db
.select()
.from(newsletterSubscription)
.where(eq(newsletterSubscription.email, normalizedEmail))
.limit(1);
if (existing) {
if (existing.status === "unsubscribed") {
await db
.update(newsletterSubscription)
.set({
status: "active",
subscribedAt: new Date(),
unsubscribedAt: null,
userId: userId ?? existing.userId,
})
.where(eq(newsletterSubscription.id, existing.id));
}
return { success: true as const };
}
await db.insert(newsletterSubscription).values({
id: nanoid(),
email: normalizedEmail,
userId,
status: "active",
unsubscribeToken: nanoid(32),
});
} catch (error) {
console.error("Newsletter subscribe failed:", error);
return { success: false as const, error: "SAVE_FAILED" };
}
// 可选:同步到 Resend Audience,失败不影响本地订阅结果
if (process.env.RESEND_API_KEY && process.env.RESEND_AUDIENCE_ID) {
try {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.contacts.create({
email: normalizedEmail,
audienceId: process.env.RESEND_AUDIENCE_ID,
unsubscribed: false,
});
} catch (error) {
console.error("Failed to sync contact to Resend:", error);
}
}
return { success: true as const };
}
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { subscribeNewsletter } from "@/features/marketing/actions/newsletter-actions";
export function NewsletterForm() {
const t = useTranslations("footer.newsletter");
const [email, setEmail] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || pending) return;
setPending(true);
try {
const result = await subscribeNewsletter(email);
if (result.success) {
toast.success(t("success"));
setEmail("");
} else {
toast.error(
result.error === "INVALID_EMAIL" ? t("invalidEmail") : t("error")
);
}
} finally {
setPending(false);
}
}
return (
<div className="mt-8 max-w-sm">
<h4 className="text-sm font-semibold text-foreground">{t("title")}</h4>
<p className="mt-1 text-xs text-muted-foreground">{t("description")}</p>
<form onSubmit={handleSubmit} className="mt-3 flex gap-2">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("placeholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="submit"
disabled={pending}
className="flex items-center gap-1 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
>
{pending && <Loader2 className="h-3 w-3 animate-spin" />}
{t("subscribe")}
</button>
</form>
</div>
);
}
@@ -1,8 +1,8 @@
"use server"; "use server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { category, toolCategory } from "@/lib/db/schema"; import { category, toolCategory, tool } from "@/lib/db/schema";
import { eq, asc, sql } from "drizzle-orm"; import { eq, asc, and, sql } from "drizzle-orm";
import { isAdmin } from "@/lib/auth/admin"; import { isAdmin } from "@/lib/auth/admin";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -43,10 +43,14 @@ export async function getCategoriesWithCount() {
const result = await db const result = await db
.select({ .select({
category, category,
toolCount: sql<number>`count(${toolCategory.toolId})`, toolCount: sql<number>`count(${tool.id})`,
}) })
.from(category) .from(category)
.leftJoin(toolCategory, eq(category.id, toolCategory.categoryId)) .leftJoin(toolCategory, eq(category.id, toolCategory.categoryId))
.leftJoin(
tool,
and(eq(toolCategory.toolId, tool.id), eq(tool.status, "published"))
)
.groupBy(category.id) .groupBy(category.id)
.orderBy(asc(category.sortOrder), asc(category.nameEn)); .orderBy(asc(category.sortOrder), asc(category.nameEn));
@@ -0,0 +1,70 @@
"use server";
import { db } from "@/lib/db";
import { favorite, tool } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
async function getSessionUserId(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user?.id ?? null;
}
/**
* 收藏/取消收藏(登录用户)
*/
export async function toggleFavorite(toolId: string) {
const userId = await getSessionUserId();
if (!userId) {
return { success: false as const, error: "UNAUTHORIZED" };
}
const [existing] = await db
.select({ toolId: favorite.toolId })
.from(favorite)
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)))
.limit(1);
if (existing) {
await db
.delete(favorite)
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)));
return { success: true as const, favorited: false };
}
await db.insert(favorite).values({ userId, toolId }).onConflictDoNothing();
return { success: true as const, favorited: true };
}
/**
* 当前用户收藏的工具 ID 列表(未登录返回空)
*/
export async function getFavoriteToolIds(): Promise<string[]> {
const userId = await getSessionUserId();
if (!userId) return [];
const rows = await db
.select({ toolId: favorite.toolId })
.from(favorite)
.where(eq(favorite.userId, userId));
return rows.map((r) => r.toolId);
}
/**
* 当前用户收藏的工具详情列表(仪表盘)
*/
export async function getFavoriteTools() {
const userId = await getSessionUserId();
if (!userId) return [];
const rows = await db
.select({ tool, favoritedAt: favorite.createdAt })
.from(favorite)
.innerJoin(tool, eq(favorite.toolId, tool.id))
.where(eq(favorite.userId, userId))
.orderBy(desc(favorite.createdAt));
return rows.map((row) => ({ ...row.tool, favoritedAt: row.favoritedAt }));
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./tool-actions"; export * from "./tool-actions";
export * from "./category-actions"; export * from "./category-actions";
export * from "./tag-actions"; export * from "./tag-actions";
export * from "./favorite-actions";
+50 -11
View File
@@ -2,13 +2,14 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { tool, toolCategory, toolTag, category, tag } from "@/lib/db/schema"; import { tool, toolCategory, toolTag, category, tag } from "@/lib/db/schema";
import { eq, desc, and, ilike, inArray, sql } from "drizzle-orm"; import { eq, desc, and, inArray, sql } from "drizzle-orm";
import { isAdmin } from "@/lib/auth/admin"; import { isAdmin } from "@/lib/auth/admin";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
// ========== 类型定义 ========== // ========== 类型定义 ==========
export type ToolStatus = "draft" | "published"; export type ToolStatus = "draft" | "published";
export type ToolPricing = "free" | "freemium" | "paid";
export interface CreateToolInput { export interface CreateToolInput {
slug: string; slug: string;
@@ -20,11 +21,24 @@ export interface CreateToolInput {
descriptionEn?: string; descriptionEn?: string;
nameZh?: string; nameZh?: string;
descriptionZh?: string; descriptionZh?: string;
pricing?: ToolPricing;
featured?: boolean;
status?: ToolStatus; status?: ToolStatus;
categoryIds?: string[]; categoryIds?: string[];
tagIds?: string[]; tagIds?: string[];
} }
// 路由实际为 /[locale]/tools/[slug],revalidatePath 必须带上动态段才能命中缓存
function revalidateToolPages(withDetail = false) {
revalidatePath("/[locale]", "page");
revalidatePath("/[locale]/tools", "page");
revalidatePath("/[locale]/category/[slug]", "page");
revalidatePath("/[locale]/admin/tools", "page");
if (withDetail) {
revalidatePath("/[locale]/tools/[slug]", "page");
}
}
export interface UpdateToolInput extends Partial<CreateToolInput> { export interface UpdateToolInput extends Partial<CreateToolInput> {
id: string; id: string;
} }
@@ -36,6 +50,7 @@ export interface ToolListParams {
categoryId?: string; categoryId?: string;
tagId?: string; tagId?: string;
search?: string; search?: string;
pricing?: ToolPricing;
} }
// ========== 查询操作 ========== // ========== 查询操作 ==========
@@ -44,7 +59,7 @@ export interface ToolListParams {
* 获取工具列表(前台) * 获取工具列表(前台)
*/ */
export async function getPublishedTools(params: ToolListParams = {}) { export async function getPublishedTools(params: ToolListParams = {}) {
const { page = 1, pageSize = 12, categoryId, tagId, search } = params; const { page = 1, pageSize = 12, categoryId, tagId, search, pricing } = params;
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
let toolIds: string[] | undefined; let toolIds: string[] | undefined;
@@ -93,6 +108,10 @@ export async function getPublishedTools(params: ToolListParams = {}) {
); );
} }
if (pricing) {
conditions.push(eq(tool.pricing, pricing));
}
// 查询工具 // 查询工具
const [tools, countResult] = await Promise.all([ const [tools, countResult] = await Promise.all([
db db
@@ -116,6 +135,31 @@ export async function getPublishedTools(params: ToolListParams = {}) {
}; };
} }
/**
* 首页精选工具:featured 优先,不足时用最新发布补齐
*/
export async function getFeaturedTools(limit = 8) {
const featuredTools = await db
.select()
.from(tool)
.where(and(eq(tool.status, "published"), eq(tool.featured, true)))
.orderBy(desc(tool.createdAt))
.limit(limit);
if (featuredTools.length >= limit) {
return featuredTools;
}
const fill = await db
.select()
.from(tool)
.where(and(eq(tool.status, "published"), eq(tool.featured, false)))
.orderBy(desc(tool.createdAt))
.limit(limit - featuredTools.length);
return [...featuredTools, ...fill];
}
/** /**
* 根据 slug 获取工具详情 * 根据 slug 获取工具详情
*/ */
@@ -264,8 +308,7 @@ export async function createTool(input: CreateToolInput) {
); );
} }
revalidatePath("/admin/tools"); revalidateToolPages();
revalidatePath("/tools");
return { success: true, id }; return { success: true, id };
} }
@@ -315,9 +358,7 @@ export async function updateTool(input: UpdateToolInput) {
} }
} }
revalidatePath("/admin/tools"); revalidateToolPages(true);
revalidatePath("/tools");
revalidatePath(`/tools/${input.slug}`);
return { success: true }; return { success: true };
} }
@@ -332,8 +373,7 @@ export async function deleteTool(id: string) {
await db.delete(tool).where(eq(tool.id, id)); await db.delete(tool).where(eq(tool.id, id));
revalidatePath("/admin/tools"); revalidateToolPages(true);
revalidatePath("/tools");
return { success: true }; return { success: true };
} }
@@ -351,7 +391,6 @@ export async function updateToolStatus(id: string, status: ToolStatus) {
.set({ status, updatedAt: new Date() }) .set({ status, updatedAt: new Date() })
.where(eq(tool.id, id)); .where(eq(tool.id, id));
revalidatePath("/admin/tools"); revalidateToolPages(true);
revalidatePath("/tools");
return { success: true }; return { success: true };
} }
@@ -0,0 +1,153 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner";
import { Heart } from "lucide-react";
import { useSession } from "@/lib/auth-client";
import {
getFavoriteToolIds,
toggleFavorite,
} from "@/features/tools/actions/favorite-actions";
import { cn } from "@/lib/utils";
interface FavoritesContextValue {
favoriteIds: Set<string>;
isAuthenticated: boolean;
toggle: (toolId: string) => void;
}
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
/**
* 客户端拉取收藏状态(页面本身可被 ISR 缓存,收藏状态不能进服务端渲染结果)
*/
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const session = useSession();
const router = useRouter();
const locale = useLocale();
const t = useTranslations("tools.favorites");
const isAuthenticated = !!session.data?.user;
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (session.isPending) return;
if (!isAuthenticated) {
setFavoriteIds(new Set());
return;
}
getFavoriteToolIds().then((ids) => setFavoriteIds(new Set(ids)));
}, [isAuthenticated, session.isPending]);
const toggle = useCallback(
(toolId: string) => {
if (!isAuthenticated) {
toast(t("loginRequired"));
router.push(`/${locale}/login`);
return;
}
// 乐观更新,失败回滚
setFavoriteIds((prev) => {
const next = new Set(prev);
if (next.has(toolId)) {
next.delete(toolId);
} else {
next.add(toolId);
}
return next;
});
toggleFavorite(toolId).then((result) => {
if (!result.success) {
setFavoriteIds((prev) => {
const next = new Set(prev);
if (next.has(toolId)) {
next.delete(toolId);
} else {
next.add(toolId);
}
return next;
});
toast.error(t("error"));
}
});
},
[isAuthenticated, locale, router, t]
);
const value = useMemo(
() => ({ favoriteIds, isAuthenticated, toggle }),
[favoriteIds, isAuthenticated, toggle]
);
return (
<FavoritesContext.Provider value={value}>
{children}
</FavoritesContext.Provider>
);
}
interface FavoriteButtonProps {
toolId: string;
appearance?: "icon" | "button";
}
export function FavoriteButton({
toolId,
appearance = "icon",
}: FavoriteButtonProps) {
const ctx = useContext(FavoritesContext);
const t = useTranslations("tools.favorites");
if (!ctx) return null;
const favorited = ctx.favoriteIds.has(toolId);
if (appearance === "button") {
return (
<button
type="button"
onClick={() => ctx.toggle(toolId)}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
favorited
? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:text-red-400"
: "border-border text-foreground hover:bg-hover"
)}
>
<Heart className={cn("h-4 w-4", favorited && "fill-current")} />
{favorited ? t("remove") : t("add")}
</button>
);
}
return (
<button
type="button"
aria-label={favorited ? t("remove") : t("add")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
ctx.toggle(toolId);
}}
className={cn(
"rounded-full p-2 transition-colors",
favorited
? "text-red-500 hover:text-red-600"
: "text-muted-foreground/50 hover:text-red-500"
)}
>
<Heart className={cn("h-4 w-4", favorited && "fill-current")} />
</button>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { Heart, Loader2 } from "lucide-react";
import { getFavoriteTools } from "@/features/tools/actions/favorite-actions";
interface FavoriteTool {
id: string;
slug: string;
domain: string | null;
logoUrl: string | null;
nameEn: string;
nameZh: string | null;
}
/**
* 仪表盘"我的收藏"列表
*/
export function FavoriteToolsSection() {
const t = useTranslations("dashboard.favorites");
const locale = useLocale();
const [tools, setTools] = useState<FavoriteTool[] | null>(null);
useEffect(() => {
getFavoriteTools().then(setTools);
}, []);
const getName = (tool: FavoriteTool) =>
locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
return (
<div className="bg-card/50 backdrop-blur-md rounded-2xl p-6 border border-border">
<h3 className="text-xl font-semibold text-card-foreground mb-4 flex items-center gap-2">
<Heart className="h-5 w-5 text-red-500" />
{t("title")}
</h3>
{tools === null ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tools.length === 0 ? (
<div className="py-6 text-center">
<p className="text-muted-foreground">{t("empty")}</p>
<Link
href={`/${locale}/tools`}
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
>
{t("browseTools")}
</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tools.map((tool) => (
<Link
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
>
{tool.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={tool.logoUrl}
alt={getName(tool)}
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-base font-bold text-muted-foreground">
{getName(tool).charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{getName(tool)}
</p>
{tool.domain && (
<p className="truncate text-sm text-muted-foreground">
{tool.domain}
</p>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}
+65 -15
View File
@@ -4,6 +4,8 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"; import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { ToolLogo } from "@/components/tool-logo";
interface ToolData { interface ToolData {
id: string; id: string;
@@ -16,6 +18,7 @@ interface ToolData {
descriptionEn: string | null; descriptionEn: string | null;
nameZh: string | null; nameZh: string | null;
descriptionZh: string | null; descriptionZh: string | null;
pricing: string | null;
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -38,12 +41,16 @@ interface ToolsTranslations {
foundTools: string; foundTools: string;
visitWebsite: string; visitWebsite: string;
noTools: string; noTools: string;
pricingFree: string;
pricingFreemium: string;
pricingPaid: string;
} }
interface ToolsListProps { interface ToolsListProps {
tools: ToolData[]; tools: ToolData[];
categories: CategoryData[]; categories: CategoryData[];
currentCategory?: string; currentCategory?: string;
currentPricing?: string;
currentSearch?: string; currentSearch?: string;
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
@@ -56,6 +63,7 @@ export function ToolsList({
tools, tools,
categories, categories,
currentCategory, currentCategory,
currentPricing,
currentSearch, currentSearch,
currentPage, currentPage,
totalPages, totalPages,
@@ -90,6 +98,17 @@ export function ToolsList({
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/${locale}/tools?${params.toString()}`);
}; };
const handlePricingChange = (pricing: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (pricing) {
params.set("pricing", pricing);
} else {
params.delete("pricing");
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
};
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString()); params.set("page", page.toString());
@@ -107,6 +126,12 @@ export function ToolsList({
const getCategoryName = (category: CategoryData) => const getCategoryName = (category: CategoryData) =>
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn; locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
const pricingLabels: Record<string, string> = {
free: translations.pricingFree,
freemium: translations.pricingFreemium,
paid: translations.pricingPaid,
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 搜索框和分类筛选 */} {/* 搜索框和分类筛选 */}
@@ -163,6 +188,33 @@ export function ToolsList({
</button> </button>
))} ))}
</div> </div>
{/* 第三行:定价筛选 */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => handlePricingChange(null)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
!currentPricing
? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover"
}`}
>
{translations.all}
</button>
{(["free", "freemium", "paid"] as const).map((pricing) => (
<button
key={pricing}
onClick={() => handlePricingChange(pricing)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
currentPricing === pricing
? "bg-foreground text-background"
: "bg-secondary text-muted-foreground hover:bg-hover"
}`}
>
{pricingLabels[pricing]}
</button>
))}
</div>
</div> </div>
{/* 工具网格 */} {/* 工具网格 */}
@@ -174,19 +226,7 @@ export function ToolsList({
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20" className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{tool.logoUrl ? ( <ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
<img
src={tool.logoUrl}
alt={getName(tool)}
className="h-12 w-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-secondary flex items-center justify-center flex-shrink-0">
<span className="text-xl font-bold text-muted-foreground">
{getName(tool).charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-primary truncate"> <h3 className="font-semibold text-foreground group-hover:text-primary truncate">
{getName(tool)} {getName(tool)}
@@ -197,6 +237,7 @@ export function ToolsList({
</p> </p>
)} )}
</div> </div>
<FavoriteButton toolId={tool.id} />
</div> </div>
{getDescription(tool) && ( {getDescription(tool) && (
@@ -205,12 +246,21 @@ export function ToolsList({
</p> </p>
)} )}
{tool.websiteUrl && ( <div className="mt-4 flex items-center justify-between">
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground"> {tool.websiteUrl ? (
<div className="flex items-center gap-1 text-sm 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>
) : (
<span />
)} )}
{tool.pricing && pricingLabels[tool.pricing] && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">
{pricingLabels[tool.pricing]}
</span>
)}
</div>
</Link> </Link>
))} ))}
</div> </div>