diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b95340e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.env +.env.* +!.env.example +Dockerfile +docker-compose.yml +*.md +.claude +.vscode +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8da8a24 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b2b5ed --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/messages/en.json b/messages/en.json index 2e345b9..4c98be9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -22,7 +22,9 @@ "update": "Update", "continue": "Continue", "back": "Back", - "next": "Next" + "next": "Next", + "contactSales": "Contact Sales", + "startTrial": "Start Free Trial" }, "status": { "loading": "Loading...", @@ -442,6 +444,11 @@ "accountSettings": "Account Settings", "securitySettings": "Security Settings" } + }, + "favorites": { + "title": "My Favorites", + "empty": "No favorite tools yet.", + "browseTools": "Browse tools and save your favorites" } }, "profile": { @@ -459,8 +466,20 @@ }, "accountSettings": { "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": { @@ -539,7 +558,8 @@ "company": { "title": "Company", "about": "About", - "blog": "Blog" + "blog": "Blog", + "careers": "Careers" }, "legal": { "title": "Legal", @@ -557,7 +577,10 @@ "title": "Subscribe to Newsletter", "description": "Get the latest updates and news directly in your inbox.", "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": { @@ -576,7 +599,21 @@ "toolInfo": "Tool Info", "domain": "Domain", "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": { "home": { @@ -623,5 +660,12 @@ "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." } + }, + "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" } } diff --git a/messages/zh.json b/messages/zh.json index cf2ca72..26da7b2 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -421,7 +421,8 @@ "previous": "上一页", "next": "下一页", "page": "第 {current} / {total} 页" - } + }, + "cancel": "取消" } }, "dashboard": { @@ -443,6 +444,11 @@ "accountSettings": "账户设置", "securitySettings": "安全设置" } + }, + "favorites": { + "title": "我的收藏", + "empty": "还没有收藏的工具。", + "browseTools": "去浏览工具并收藏" } }, "profile": { @@ -579,7 +585,10 @@ "title": "订阅邮件", "description": "获取最新动态和资讯,直接发送到您的邮箱。", "placeholder": "输入您的邮箱", - "subscribe": "订阅" + "subscribe": "订阅", + "success": "订阅成功!感谢加入。", + "invalidEmail": "请输入有效的邮箱地址。", + "error": "订阅失败,请重试。" } }, "tools": { @@ -598,7 +607,21 @@ "toolInfo": "工具信息", "domain": "域名", "added": "添加时间", - "notFound": "工具未找到" + "notFound": "工具未找到", + "favorites": { + "add": "收藏", + "remove": "取消收藏", + "loginRequired": "请先登录后再收藏", + "error": "操作失败,请重试。" + }, + "pricing": { + "free": "免费", + "freemium": "免费增值", + "paid": "付费" + }, + "categoryPage": { + "viewAll": "查看该分类全部 {count} 个工具" + } }, "seo": { "home": { @@ -645,5 +668,12 @@ "title": "AI 工具库 - 发现最好的 AI 工具", "description": "浏览精选的 AI 工具集合。找到提升效率、激发创意的最佳 AI 工具。" } + }, + "featuredTools": { + "title": "精选工具", + "subtitle": "精选优质 AI 工具,助力提升工作效率", + "visitWebsite": "访问网站", + "empty": "暂无工具,请先在后台添加", + "viewAll": "查看全部工具" } } diff --git a/next.config.mjs b/next.config.mjs index 95f3cd7..9db019f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -13,6 +13,8 @@ const snapshotManagedPaths = fs.existsSync(pnpmManagedPath) ? [pnpmManagedPath] /** @type {import('next').NextConfig} */ const nextConfig = { + // Docker 部署使用 standalone 输出 + output: "standalone", images: { remotePatterns: [ { @@ -31,6 +33,25 @@ const nextConfig = { protocol: "https", 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"], diff --git a/package.json b/package.json index 3adb00a..1df4d05 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "admin:setup": "tsx scripts/setup-admin.ts", "seed:tools": "tsx scripts/seed-tools.ts", "generate:blog-manifest": "node scripts/generate-blog-manifest.mjs", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "i18n:check": "node scripts/check-i18n.mjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.958.0", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..204d51e --- /dev/null +++ b/scripts/check-i18n.mjs @@ -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} 个键)`); diff --git a/src/app/[locale]/(auth)/reset-password/page.tsx b/src/app/[locale]/(auth)/reset-password/page.tsx index 7966ade..44c9079 100644 --- a/src/app/[locale]/(auth)/reset-password/page.tsx +++ b/src/app/[locale]/(auth)/reset-password/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { Suspense, useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { z } from "zod"; @@ -24,7 +24,7 @@ const resetPasswordSchema = z.object({ type ResetPasswordInput = z.infer; -export default function ResetPasswordPage() { +function ResetPasswordPageInner() { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); @@ -186,3 +186,11 @@ export default function ResetPasswordPage() { ); } + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/src/app/[locale]/(marketing)/category/[slug]/page.tsx b/src/app/[locale]/(marketing)/category/[slug]/page.tsx index fec8949..4b31226 100644 --- a/src/app/[locale]/(marketing)/category/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/category/[slug]/page.tsx @@ -1,38 +1,187 @@ -import { notFound, redirect } from "next/navigation"; -import { getCategoryBySlug } from "@/features/tools/actions"; -import { getLocale } from "next-intl/server"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +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 { params: Promise<{ + locale: string; slug: string; }>; } +// 分类落地页走 ISR,工具增删时由 revalidatePath 触发更新 +export const revalidate = 3600; + +const PAGE_SIZE = 24; + +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 []; + } +} + export async function generateMetadata({ params }: CategoryPageProps) { - const { slug } = await params; + const { slug, locale } = await params; const category = await getCategoryBySlug(slug); + const t = await getTranslations({ locale, namespace: "tools" }); if (!category) { - return { - title: "Category Not Found", - }; + return { title: t("notFound") }; } - return { - title: `${category.nameEn} - AI Tools`, - description: category.descriptionEn || `Explore ${category.nameEn} AI tools`, - }; + 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) { - const { slug } = await params; - const locale = await getLocale(); + const { slug, locale } = await params; + setRequestLocale(locale); const category = await getCategoryBySlug(slug); if (!category) { notFound(); } - // 重定向到工具列表页并带上分类参数 - redirect(`/${locale}/tools?category=${slug}`); + const t = await getTranslations({ locale, namespace: "tools" }); + 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 ( +
+ + + {/* 分类头部 */} +
+ {category.icon &&
{category.icon}
} +

{name}

+ {description && ( +

{description}

+ )} +

+ {t("foundTools", { count: total })} +

+
+ + {/* 工具网格 */} + +
+ {tools.map((tool) => ( + +
+ +
+

+ {getName(tool)} +

+ {tool.domain && ( +

+ {tool.domain} +

+ )} +
+ +
+ + {getDescription(tool) && ( +

+ {getDescription(tool)} +

+ )} + + {tool.websiteUrl && ( +
+ + {t("visitWebsite")} +
+ )} + + ))} +
+
+ + {/* 空状态 */} + {tools.length === 0 && ( +
+ {t("noTools")} +
+ )} + + {/* 查看全部 */} + {total > PAGE_SIZE && ( +
+ + {t("categoryPage.viewAll", { count: total })} + + +
+ )} +
+ ); } diff --git a/src/app/[locale]/(marketing)/page.tsx b/src/app/[locale]/(marketing)/page.tsx index 4ade0d6..761f3ea 100644 --- a/src/app/[locale]/(marketing)/page.tsx +++ b/src/app/[locale]/(marketing)/page.tsx @@ -5,10 +5,10 @@ import { CTA } from "@/components/cta"; import { FeaturedTools } from "@/components/featured-tools"; import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld"; 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 { generatePageMetadata } from "@/lib/metadata"; -import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions"; +import { getFeaturedTools, getCategoriesWithCount } from "@/features/tools/actions"; export async function generateMetadata({ params @@ -26,15 +26,29 @@ export async function generateMetadata({ }); } -export default async function Home() { - const locale = await getLocale(); +// 首页内容变化不频繁,ISR 每小时再生 +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 [{ tools }, categories] = await Promise.all([ - getPublishedTools({ pageSize: 8 }), - getCategoriesWithCount(), - ]); + // 获取精选工具和分类数据(构建时数据库不可用则先渲染空态,运行时 ISR 补全) + let tools: Awaited> = []; + let categories: Awaited> = []; + try { + [tools, categories] = await Promise.all([ + getFeaturedTools(8), + getCategoriesWithCount(), + ]); + } catch { + console.warn("home: database unavailable at build time"); + } return (
diff --git a/src/app/[locale]/(marketing)/tools/[slug]/page.tsx b/src/app/[locale]/(marketing)/tools/[slug]/page.tsx index 21ef1ec..34f4304 100644 --- a/src/app/[locale]/(marketing)/tools/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/tools/[slug]/page.tsx @@ -1,22 +1,51 @@ import { notFound } from "next/navigation"; 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 { 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 { Button } from "@/components/button"; import { SoftwareApplicationJsonLd } from "@/components/json-ld"; import { generatePageMetadata } from "@/lib/metadata"; +import { + FavoritesProvider, + FavoriteButton, +} from "@/features/tools/components/favorite-button"; +import { ToolLogo } from "@/components/tool-logo"; interface ToolDetailPageProps { params: Promise<{ + locale: 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) { - const { slug } = await params; - const locale = await getLocale(); - const t = await getTranslations("tools"); + const { slug, locale } = await params; + const t = await getTranslations({ locale, namespace: "tools" }); const tool = await getToolBySlug(slug); if (!tool) { @@ -41,9 +70,9 @@ export async function generateMetadata({ params }: ToolDetailPageProps) { } export default async function ToolDetailPage({ params }: ToolDetailPageProps) { - const { slug } = await params; - const locale = await getLocale(); - const t = await getTranslations("tools"); + const { slug, locale } = await params; + setRequestLocale(locale); + const t = await getTranslations({ locale, namespace: "tools" }); const tool = await getToolBySlug(slug); if (!tool) { @@ -91,19 +120,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* 工具头部 */}
- {tool.logoUrl ? ( - {name} - ) : ( -
- - {name.charAt(0).toUpperCase()} - -
- )} +

{name} @@ -117,10 +134,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) { {/* 封面图 */} {tool.coverImageUrl && (
- {name}
)} @@ -199,6 +219,11 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) { )} + {/* 收藏按钮 */} + + + + {/* 工具信息卡片 */}

diff --git a/src/app/[locale]/(marketing)/tools/page.tsx b/src/app/[locale]/(marketing)/tools/page.tsx index a0b9d34..bb20a78 100644 --- a/src/app/[locale]/(marketing)/tools/page.tsx +++ b/src/app/[locale]/(marketing)/tools/page.tsx @@ -1,5 +1,6 @@ import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions"; import { ToolsList } from "@/features/tools/components/tools-list"; +import { FavoritesProvider } from "@/features/tools/components/favorite-button"; import { CollectionPageJsonLd } from "@/components/json-ld"; import { getLocale, getTranslations } from "next-intl/server"; import { generatePageMetadata } from "@/lib/metadata"; @@ -24,6 +25,7 @@ interface ToolsPageProps { page?: string; category?: string; search?: string; + pricing?: string; }>; } @@ -34,6 +36,11 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) { const page = parseInt(params.page || "1", 10); const categorySlug = params.category; const search = params.search; + const pricingParam = params.pricing; + const pricing = + pricingParam === "free" || pricingParam === "freemium" || pricingParam === "paid" + ? pricingParam + : undefined; // 获取分类列表用于筛选 const categories = await getCategoriesWithCount(); @@ -51,6 +58,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) { pageSize: 12, categoryId, search, + pricing, }); const totalPages = Math.ceil(total / pageSize); @@ -64,6 +72,9 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) { foundTools: t("foundTools", { count: total }), visitWebsite: t("visitWebsite"), 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'; @@ -87,17 +98,20 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {

- + + +

); } diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 8041451..d841966 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/button"; import { Container } from "@/components/container"; import { Background } from "@/components/background"; import { motion } from "framer-motion"; +import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section"; export default function DashboardPage() { const router = useRouter(); @@ -134,6 +135,15 @@ export default function DashboardPage() { Sign Out + + + +
); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index bf9d079..3ca8631 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -10,6 +10,10 @@ import { Toaster } from "sonner"; import Analytics from "@/analytics/analytics"; import "../globals.css"; +export function generateStaticParams() { + return locales.map((locale) => ({ locale })); +} + export default async function LocaleLayout({ children, params diff --git a/src/app/[locale]/verify-email/page.tsx b/src/app/[locale]/verify-email/page.tsx index ce8b4e0..b72f5cb 100644 --- a/src/app/[locale]/verify-email/page.tsx +++ b/src/app/[locale]/verify-email/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react'; import Link from 'next/link'; import { useLocale, useTranslations } from 'next-intl'; -export default function VerifyEmailPage() { +function VerifyEmailPageInner() { const searchParams = useSearchParams(); const router = useRouter(); const locale = useLocale(); @@ -140,4 +140,11 @@ export default function VerifyEmailPage() {
); -} \ No newline at end of file +} +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts new file mode 100644 index 0000000..2c8a2b1 --- /dev/null +++ b/src/app/api/newsletter/unsubscribe/route.ts @@ -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( + "

Unsubscribed

You have been unsubscribed from the Toolso.AI newsletter.

", + { headers: { "Content-Type": "text/html; charset=utf-8" } } + ); +} diff --git a/src/app/api/upload/image/route.ts b/src/app/api/upload/image/route.ts index b07bd69..470cade 100644 --- a/src/app/api/upload/image/route.ts +++ b/src/app/api/upload/image/route.ts @@ -2,56 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; 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) { try { // Authenticate user @@ -87,21 +37,6 @@ export async function POST(req: NextRequest) { const bytes = await file.arrayBuffer(); 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 const timestamp = Date.now(); const random = Math.random().toString(36).substring(7); diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 4a5512d..191c7cc 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -21,14 +21,23 @@ export default async function sitemap(): Promise { { path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 }, ] - const [tools, categories, blogs] = await Promise.all([ - db - .select({ slug: tool.slug, updatedAt: tool.updatedAt }) - .from(tool) - .where(eq(tool.status, 'published')), - db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category), - getAllBlogs(), - ]) + // 构建环境可能连不上数据库(如 Docker 镜像构建),退化为仅静态路由, + // 运行时依靠 revalidate 补全动态条目 + let tools: { slug: string; updatedAt: Date }[] = [] + let categories: { slug: string; updatedAt: Date }[] = [] + let blogs: Awaited> = [] + try { + ;[tools, categories, blogs] = await Promise.all([ + db + .select({ slug: tool.slug, updatedAt: tool.updatedAt }) + .from(tool) + .where(eq(tool.status, 'published')), + db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category), + getAllBlogs(), + ]) + } catch (error) { + console.warn('sitemap: database unavailable, falling back to static routes only') + } const dynamicRoutes = [ ...tools.map((t) => ({ diff --git a/src/components/featured-tools.tsx b/src/components/featured-tools.tsx index 0eb8af8..2ba5ce0 100644 --- a/src/components/featured-tools.tsx +++ b/src/components/featured-tools.tsx @@ -1,10 +1,11 @@ "use client"; import Link from "next/link"; -import { useLocale } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { ExternalLink, ArrowRight } from "lucide-react"; import { Heading } from "./heading"; import { Subheading } from "./subheading"; +import { ToolLogo } from "./tool-logo"; interface ToolData { id: string; @@ -34,6 +35,7 @@ interface FeaturedToolsProps { export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { const locale = useLocale(); + const t = useTranslations("featuredTools"); const getName = (item: { nameEn: string; nameZh: string | null }) => locale === "zh" && item.nameZh ? item.nameZh : item.nameEn; @@ -45,14 +47,8 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { return (
- - {locale === "zh" ? "精选工具" : "Featured Tools"} - - - {locale === "zh" - ? "精选优质 AI 工具,助力提升工作效率" - : "Curated collection of AI tools to boost your productivity"} - + {t("title")} + {t("subtitle")} {/* 分类导航 */} {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" >
- {tool.logoUrl ? ( - {getName(tool)} - ) : ( -
- - {getName(tool).charAt(0).toUpperCase()} - -
- )} +

{getName(tool)} @@ -116,7 +100,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { {tool.websiteUrl && (
- {locale === "zh" ? "访问网站" : "Visit website"} + {t("visitWebsite")}
)} @@ -126,7 +110,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { {/* 空状态 */} {tools.length === 0 && (
- {locale === "zh" ? "暂无工具,请先在后台添加" : "No tools yet. Add some in the admin panel."} + {t("empty")}
)} @@ -137,7 +121,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) { 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" > - {locale === "zh" ? "查看全部工具" : "View All Tools"} + {t("viewAll")}

diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 35b6e40..d68bac8 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Logo } from "./Logo"; import { useTranslations } from 'next-intl'; import { LocaleLink } from './locale-link'; +import { NewsletterForm } from '@/features/marketing/components/newsletter-form'; export const Footer = () => { const t = useTranslations(); @@ -55,6 +56,7 @@ export const Footer = () => {
{t('common.brand.copyright')}
{t('common.brand.allRightsReserved')}
+
diff --git a/src/components/tool-logo.tsx b/src/components/tool-logo.tsx new file mode 100644 index 0000000..ac3851b --- /dev/null +++ b/src/components/tool-logo.tsx @@ -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 ( +
+ + {alt.charAt(0).toUpperCase()} + +
+ ); + } + + if (src.startsWith("data:")) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); + } + + return ( + {alt} + ); +} diff --git a/src/features/marketing/actions/newsletter-actions.ts b/src/features/marketing/actions/newsletter-actions.ts new file mode 100644 index 0000000..6f32df3 --- /dev/null +++ b/src/features/marketing/actions/newsletter-actions.ts @@ -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 }; +} diff --git a/src/features/marketing/components/newsletter-form.tsx b/src/features/marketing/components/newsletter-form.tsx new file mode 100644 index 0000000..522f545 --- /dev/null +++ b/src/features/marketing/components/newsletter-form.tsx @@ -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 ( +
+

{t("title")}

+

{t("description")}

+
+ 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" + /> + +
+
+ ); +} diff --git a/src/features/tools/actions/category-actions.ts b/src/features/tools/actions/category-actions.ts index bd4041c..561c0ec 100644 --- a/src/features/tools/actions/category-actions.ts +++ b/src/features/tools/actions/category-actions.ts @@ -1,8 +1,8 @@ "use server"; import { db } from "@/lib/db"; -import { category, toolCategory } from "@/lib/db/schema"; -import { eq, asc, sql } from "drizzle-orm"; +import { category, toolCategory, tool } from "@/lib/db/schema"; +import { eq, asc, and, sql } from "drizzle-orm"; import { isAdmin } from "@/lib/auth/admin"; import { revalidatePath } from "next/cache"; import { nanoid } from "nanoid"; @@ -43,10 +43,14 @@ export async function getCategoriesWithCount() { const result = await db .select({ category, - toolCount: sql`count(${toolCategory.toolId})`, + toolCount: sql`count(${tool.id})`, }) .from(category) .leftJoin(toolCategory, eq(category.id, toolCategory.categoryId)) + .leftJoin( + tool, + and(eq(toolCategory.toolId, tool.id), eq(tool.status, "published")) + ) .groupBy(category.id) .orderBy(asc(category.sortOrder), asc(category.nameEn)); diff --git a/src/features/tools/actions/favorite-actions.ts b/src/features/tools/actions/favorite-actions.ts new file mode 100644 index 0000000..39d2b05 --- /dev/null +++ b/src/features/tools/actions/favorite-actions.ts @@ -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 { + 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 { + 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 })); +} diff --git a/src/features/tools/actions/index.ts b/src/features/tools/actions/index.ts index a24ea04..703e8c5 100644 --- a/src/features/tools/actions/index.ts +++ b/src/features/tools/actions/index.ts @@ -1,3 +1,4 @@ export * from "./tool-actions"; export * from "./category-actions"; export * from "./tag-actions"; +export * from "./favorite-actions"; diff --git a/src/features/tools/actions/tool-actions.ts b/src/features/tools/actions/tool-actions.ts index b34f9e1..e6d0d3a 100644 --- a/src/features/tools/actions/tool-actions.ts +++ b/src/features/tools/actions/tool-actions.ts @@ -2,13 +2,14 @@ import { db } from "@/lib/db"; 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 { revalidatePath } from "next/cache"; import { nanoid } from "nanoid"; // ========== 类型定义 ========== export type ToolStatus = "draft" | "published"; +export type ToolPricing = "free" | "freemium" | "paid"; export interface CreateToolInput { slug: string; @@ -20,11 +21,24 @@ export interface CreateToolInput { descriptionEn?: string; nameZh?: string; descriptionZh?: string; + pricing?: ToolPricing; + featured?: boolean; status?: ToolStatus; categoryIds?: 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 { id: string; } @@ -36,6 +50,7 @@ export interface ToolListParams { categoryId?: string; tagId?: string; search?: string; + pricing?: ToolPricing; } // ========== 查询操作 ========== @@ -44,7 +59,7 @@ export interface 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; 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([ 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 获取工具详情 */ @@ -264,8 +308,7 @@ export async function createTool(input: CreateToolInput) { ); } - revalidatePath("/admin/tools"); - revalidatePath("/tools"); + revalidateToolPages(); return { success: true, id }; } @@ -315,9 +358,7 @@ export async function updateTool(input: UpdateToolInput) { } } - revalidatePath("/admin/tools"); - revalidatePath("/tools"); - revalidatePath(`/tools/${input.slug}`); + revalidateToolPages(true); return { success: true }; } @@ -332,8 +373,7 @@ export async function deleteTool(id: string) { await db.delete(tool).where(eq(tool.id, id)); - revalidatePath("/admin/tools"); - revalidatePath("/tools"); + revalidateToolPages(true); return { success: true }; } @@ -351,7 +391,6 @@ export async function updateToolStatus(id: string, status: ToolStatus) { .set({ status, updatedAt: new Date() }) .where(eq(tool.id, id)); - revalidatePath("/admin/tools"); - revalidatePath("/tools"); + revalidateToolPages(true); return { success: true }; } diff --git a/src/features/tools/components/favorite-button.tsx b/src/features/tools/components/favorite-button.tsx new file mode 100644 index 0000000..9edc855 --- /dev/null +++ b/src/features/tools/components/favorite-button.tsx @@ -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; + isAuthenticated: boolean; + toggle: (toolId: string) => void; +} + +const FavoritesContext = createContext(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>(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 ( + + {children} + + ); +} + +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 ( + + ); + } + + return ( + + ); +} diff --git a/src/features/tools/components/favorite-tools-section.tsx b/src/features/tools/components/favorite-tools-section.tsx new file mode 100644 index 0000000..5bf3c0f --- /dev/null +++ b/src/features/tools/components/favorite-tools-section.tsx @@ -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(null); + + useEffect(() => { + getFavoriteTools().then(setTools); + }, []); + + const getName = (tool: FavoriteTool) => + locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn; + + return ( +
+

+ + {t("title")} +

+ + {tools === null ? ( +
+ +
+ ) : tools.length === 0 ? ( +
+

{t("empty")}

+ + {t("browseTools")} + +
+ ) : ( +
+ {tools.map((tool) => ( + + {tool.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {getName(tool)} + ) : ( +
+ {getName(tool).charAt(0).toUpperCase()} +
+ )} +
+

+ {getName(tool)} +

+ {tool.domain && ( +

+ {tool.domain} +

+ )} +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/features/tools/components/tools-list.tsx b/src/features/tools/components/tools-list.tsx index 5a46b4d..f0d1789 100644 --- a/src/features/tools/components/tools-list.tsx +++ b/src/features/tools/components/tools-list.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"; +import { FavoriteButton } from "@/features/tools/components/favorite-button"; +import { ToolLogo } from "@/components/tool-logo"; interface ToolData { id: string; @@ -16,6 +18,7 @@ interface ToolData { descriptionEn: string | null; nameZh: string | null; descriptionZh: string | null; + pricing: string | null; status: string; createdAt: Date; updatedAt: Date; @@ -38,12 +41,16 @@ interface ToolsTranslations { foundTools: string; visitWebsite: string; noTools: string; + pricingFree: string; + pricingFreemium: string; + pricingPaid: string; } interface ToolsListProps { tools: ToolData[]; categories: CategoryData[]; currentCategory?: string; + currentPricing?: string; currentSearch?: string; currentPage: number; totalPages: number; @@ -56,6 +63,7 @@ export function ToolsList({ tools, categories, currentCategory, + currentPricing, currentSearch, currentPage, totalPages, @@ -90,6 +98,17 @@ export function ToolsList({ 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 params = new URLSearchParams(searchParams.toString()); params.set("page", page.toString()); @@ -107,6 +126,12 @@ export function ToolsList({ const getCategoryName = (category: CategoryData) => locale === "zh" && category.nameZh ? category.nameZh : category.nameEn; + const pricingLabels: Record = { + free: translations.pricingFree, + freemium: translations.pricingFreemium, + paid: translations.pricingPaid, + }; + return (
{/* 搜索框和分类筛选 */} @@ -163,6 +188,33 @@ export function ToolsList({ ))}
+ + {/* 第三行:定价筛选 */} +
+ + {(["free", "freemium", "paid"] as const).map((pricing) => ( + + ))} +
{/* 工具网格 */} @@ -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" >
- {tool.logoUrl ? ( - {getName(tool)} - ) : ( -
- - {getName(tool).charAt(0).toUpperCase()} - -
- )} +

{getName(tool)} @@ -197,6 +237,7 @@ export function ToolsList({

)}

+
{getDescription(tool) && ( @@ -205,12 +246,21 @@ export function ToolsList({

)} - {tool.websiteUrl && ( -
- - {translations.visitWebsite} -
- )} +
+ {tool.websiteUrl ? ( +
+ + {translations.visitWebsite} +
+ ) : ( + + )} + {tool.pricing && pricingLabels[tool.pricing] && ( + + {pricingLabels[tool.pricing]} + + )} +
))}